mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Improve cluster connection visibility (#4266)
This commit is contained in:
parent
23d5d40d62
commit
3522485c6f
@ -26,7 +26,7 @@ import { Cluster } from "../main/cluster";
|
|||||||
import migrations from "../migrations/cluster-store";
|
import migrations from "../migrations/cluster-store";
|
||||||
import logger from "../main/logger";
|
import logger from "../main/logger";
|
||||||
import { appEventBus } from "./event-bus";
|
import { appEventBus } from "./event-bus";
|
||||||
import { ipcMainHandle, ipcMainOn, ipcRendererOn, requestMain } from "./ipc";
|
import { ipcMainHandle, requestMain } from "./ipc";
|
||||||
import { disposer, toJS } from "./utils";
|
import { disposer, toJS } from "./utils";
|
||||||
import type { ClusterModel, ClusterId, ClusterState } from "./cluster-types";
|
import type { ClusterModel, ClusterId, ClusterState } from "./cluster-types";
|
||||||
|
|
||||||
@ -37,8 +37,6 @@ export interface ClusterStoreModel {
|
|||||||
const initialStates = "cluster:states";
|
const initialStates = "cluster:states";
|
||||||
|
|
||||||
export class ClusterStore extends BaseStore<ClusterStoreModel> {
|
export class ClusterStore extends BaseStore<ClusterStoreModel> {
|
||||||
private static StateChannel = "cluster:state";
|
|
||||||
|
|
||||||
clusters = observable.map<ClusterId, Cluster>();
|
clusters = observable.map<ClusterId, Cluster>();
|
||||||
|
|
||||||
protected disposer = disposer();
|
protected disposer = disposer();
|
||||||
@ -83,21 +81,13 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleStateChange = (event: any, clusterId: string, state: ClusterState) => {
|
|
||||||
logger.silly(`[CLUSTER-STORE]: received push-state at ${location.host} (${webFrame.routingId})`, clusterId, state);
|
|
||||||
this.getById(clusterId)?.setState(state);
|
|
||||||
};
|
|
||||||
|
|
||||||
registerIpcListener() {
|
registerIpcListener() {
|
||||||
logger.info(`[CLUSTER-STORE] start to listen (${webFrame.routingId})`);
|
logger.info(`[CLUSTER-STORE] start to listen (${webFrame.routingId})`);
|
||||||
|
const ipc = ipcMain ?? ipcRenderer;
|
||||||
|
|
||||||
if (ipcMain) {
|
ipc?.on("cluster:state", (event, clusterId: ClusterId, state: ClusterState) => {
|
||||||
this.disposer.push(ipcMainOn(ClusterStore.StateChannel, this.handleStateChange));
|
this.getById(clusterId)?.setState(state);
|
||||||
}
|
});
|
||||||
|
|
||||||
if (ipcRenderer) {
|
|
||||||
this.disposer.push(ipcRendererOn(ClusterStore.StateChannel, this.handleStateChange));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
unregisterIpcListener() {
|
unregisterIpcListener() {
|
||||||
|
|||||||
@ -122,6 +122,14 @@ export enum ClusterStatus {
|
|||||||
Offline = 0,
|
Offline = 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The message format for the "cluster:<cluster-id>:connection-update" channels
|
||||||
|
*/
|
||||||
|
export interface KubeAuthUpdate {
|
||||||
|
message: string;
|
||||||
|
isError: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The OpenLens known static metadata keys
|
* The OpenLens known static metadata keys
|
||||||
*/
|
*/
|
||||||
@ -173,7 +181,6 @@ export interface ClusterState {
|
|||||||
disconnected: boolean;
|
disconnected: boolean;
|
||||||
accessible: boolean;
|
accessible: boolean;
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
failureReason: string;
|
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
allowedNamespaces: string[]
|
allowedNamespaces: string[]
|
||||||
allowedResources: string[]
|
allowedResources: string[]
|
||||||
|
|||||||
@ -55,7 +55,7 @@ import { ChildProcess, spawn } from "child_process";
|
|||||||
import { bundledKubectlPath, Kubectl } from "../kubectl";
|
import { bundledKubectlPath, Kubectl } from "../kubectl";
|
||||||
import { mock, MockProxy } from "jest-mock-extended";
|
import { mock, MockProxy } from "jest-mock-extended";
|
||||||
import { waitUntilUsed } from "tcp-port-used";
|
import { waitUntilUsed } from "tcp-port-used";
|
||||||
import type { Readable } from "stream";
|
import { EventEmitter, Readable } from "stream";
|
||||||
import { UserStore } from "../../common/user-store";
|
import { UserStore } from "../../common/user-store";
|
||||||
import { Console } from "console";
|
import { Console } from "console";
|
||||||
import { stdout, stderr } from "process";
|
import { stdout, stderr } from "process";
|
||||||
@ -134,30 +134,73 @@ describe("kube auth proxy tests", () => {
|
|||||||
|
|
||||||
describe("spawn tests", () => {
|
describe("spawn tests", () => {
|
||||||
let mockedCP: MockProxy<ChildProcess>;
|
let mockedCP: MockProxy<ChildProcess>;
|
||||||
let listeners: Record<string, (...args: any[]) => void>;
|
let listeners: EventEmitter;
|
||||||
let proxy: KubeAuthProxy;
|
let proxy: KubeAuthProxy;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
mockedCP = mock<ChildProcess>();
|
mockedCP = mock<ChildProcess>();
|
||||||
listeners = {};
|
listeners = new EventEmitter();
|
||||||
|
|
||||||
jest.spyOn(Kubectl.prototype, "checkBinary").mockReturnValueOnce(Promise.resolve(true));
|
jest.spyOn(Kubectl.prototype, "checkBinary").mockReturnValueOnce(Promise.resolve(true));
|
||||||
jest.spyOn(Kubectl.prototype, "ensureKubectl").mockReturnValueOnce(Promise.resolve(false));
|
jest.spyOn(Kubectl.prototype, "ensureKubectl").mockReturnValueOnce(Promise.resolve(false));
|
||||||
mockedCP.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): ChildProcess => {
|
mockedCP.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): ChildProcess => {
|
||||||
listeners[event] = listener;
|
listeners.on(event, listener);
|
||||||
|
|
||||||
return mockedCP;
|
return mockedCP;
|
||||||
});
|
});
|
||||||
mockedCP.stderr = mock<Readable>();
|
mockedCP.stderr = mock<Readable>();
|
||||||
mockedCP.stderr.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => {
|
mockedCP.stderr.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => {
|
||||||
listeners[`stderr/${event}`] = listener;
|
listeners.on(`stderr/${event}`, listener);
|
||||||
|
|
||||||
|
return mockedCP.stderr;
|
||||||
|
});
|
||||||
|
mockedCP.stderr.off.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => {
|
||||||
|
listeners.off(`stderr/${event}`, listener);
|
||||||
|
|
||||||
|
return mockedCP.stderr;
|
||||||
|
});
|
||||||
|
mockedCP.stderr.removeListener.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => {
|
||||||
|
listeners.off(`stderr/${event}`, listener);
|
||||||
|
|
||||||
|
return mockedCP.stderr;
|
||||||
|
});
|
||||||
|
mockedCP.stderr.once.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => {
|
||||||
|
listeners.once(`stderr/${event}`, listener);
|
||||||
|
|
||||||
|
return mockedCP.stderr;
|
||||||
|
});
|
||||||
|
mockedCP.stderr.removeAllListeners.mockImplementation((event?: string): Readable => {
|
||||||
|
listeners.removeAllListeners(event ?? `stderr/${event}`);
|
||||||
|
|
||||||
return mockedCP.stderr;
|
return mockedCP.stderr;
|
||||||
});
|
});
|
||||||
mockedCP.stdout = mock<Readable>();
|
mockedCP.stdout = mock<Readable>();
|
||||||
mockedCP.stdout.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => {
|
mockedCP.stdout.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => {
|
||||||
listeners[`stdout/${event}`] = listener;
|
listeners.on(`stdout/${event}`, listener);
|
||||||
listeners[`stdout/${event}`]("Starting to serve on 127.0.0.1:9191");
|
|
||||||
|
if (event === "data") {
|
||||||
|
listeners.emit("stdout/data", "Starting to serve on 127.0.0.1:9191");
|
||||||
|
}
|
||||||
|
|
||||||
|
return mockedCP.stdout;
|
||||||
|
});
|
||||||
|
mockedCP.stdout.once.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => {
|
||||||
|
listeners.once(`stdout/${event}`, listener);
|
||||||
|
|
||||||
|
return mockedCP.stdout;
|
||||||
|
});
|
||||||
|
mockedCP.stdout.off.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => {
|
||||||
|
listeners.off(`stdout/${event}`, listener);
|
||||||
|
|
||||||
|
return mockedCP.stdout;
|
||||||
|
});
|
||||||
|
mockedCP.stdout.removeListener.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => {
|
||||||
|
listeners.off(`stdout/${event}`, listener);
|
||||||
|
|
||||||
|
return mockedCP.stdout;
|
||||||
|
});
|
||||||
|
mockedCP.stdout.removeAllListeners.mockImplementation((event?: string): Readable => {
|
||||||
|
listeners.removeAllListeners(event ?? `stdout/${event}`);
|
||||||
|
|
||||||
return mockedCP.stdout;
|
return mockedCP.stdout;
|
||||||
});
|
});
|
||||||
@ -175,36 +218,36 @@ describe("kube auth proxy tests", () => {
|
|||||||
|
|
||||||
it("should call spawn and broadcast errors", async () => {
|
it("should call spawn and broadcast errors", async () => {
|
||||||
await proxy.run();
|
await proxy.run();
|
||||||
listeners["error"]({ message: "foobarbat" });
|
listeners.emit("error", { message: "foobarbat" });
|
||||||
|
|
||||||
expect(mockBroadcastIpc).toBeCalledWith("kube-auth:foobar", { data: "foobarbat", error: true });
|
expect(mockBroadcastIpc).toBeCalledWith("cluster:foobar:connection-update", { message: "foobarbat", isError: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should call spawn and broadcast exit", async () => {
|
it("should call spawn and broadcast exit", async () => {
|
||||||
await proxy.run();
|
await proxy.run();
|
||||||
listeners["exit"](0);
|
listeners.emit("exit", 0);
|
||||||
|
|
||||||
expect(mockBroadcastIpc).toBeCalledWith("kube-auth:foobar", { data: "proxy exited with code: 0", error: false });
|
expect(mockBroadcastIpc).toBeCalledWith("cluster:foobar:connection-update", { message: "proxy exited with code: 0", isError: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should call spawn and broadcast errors from stderr", async () => {
|
it("should call spawn and broadcast errors from stderr", async () => {
|
||||||
await proxy.run();
|
await proxy.run();
|
||||||
listeners["stderr/data"]("an error");
|
listeners.emit("stderr/data", "an error");
|
||||||
|
|
||||||
expect(mockBroadcastIpc).toBeCalledWith("kube-auth:foobar", { data: "an error", error: true });
|
expect(mockBroadcastIpc).toBeCalledWith("cluster:foobar:connection-update", { message: "an error", isError: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should call spawn and broadcast stdout serving info", async () => {
|
it("should call spawn and broadcast stdout serving info", async () => {
|
||||||
await proxy.run();
|
await proxy.run();
|
||||||
|
|
||||||
expect(mockBroadcastIpc).toBeCalledWith("kube-auth:foobar", { data: "Authentication proxy started\n" });
|
expect(mockBroadcastIpc).toBeCalledWith("cluster:foobar:connection-update", { message: "Authentication proxy started", isError: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should call spawn and broadcast stdout other info", async () => {
|
it("should call spawn and broadcast stdout other info", async () => {
|
||||||
await proxy.run();
|
await proxy.run();
|
||||||
listeners["stdout/data"]("some info");
|
listeners.emit("stdout/data", "some info");
|
||||||
|
|
||||||
expect(mockBroadcastIpc).toBeCalledWith("kube-auth:foobar", { data: "some info" });
|
expect(mockBroadcastIpc).toBeCalledWith("cluster:foobar:connection-update", { message: "some info", isError: false });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -32,7 +32,7 @@ import logger from "./logger";
|
|||||||
import { VersionDetector } from "./cluster-detectors/version-detector";
|
import { VersionDetector } from "./cluster-detectors/version-detector";
|
||||||
import { DetectorRegistry } from "./cluster-detectors/detector-registry";
|
import { DetectorRegistry } from "./cluster-detectors/detector-registry";
|
||||||
import plimit from "p-limit";
|
import plimit from "p-limit";
|
||||||
import type { ClusterState, ClusterRefreshOptions, ClusterMetricsResourceType, ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences, UpdateClusterModel } from "../common/cluster-types";
|
import type { ClusterState, ClusterRefreshOptions, ClusterMetricsResourceType, ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences, UpdateClusterModel, KubeAuthUpdate } from "../common/cluster-types";
|
||||||
import { ClusterMetadataKey, initialNodeShellImage, ClusterStatus } from "../common/cluster-types";
|
import { ClusterMetadataKey, initialNodeShellImage, ClusterStatus } from "../common/cluster-types";
|
||||||
import { storedKubeConfigFolder, toJS } from "../common/utils";
|
import { storedKubeConfigFolder, toJS } from "../common/utils";
|
||||||
import type { Response } from "request";
|
import type { Response } from "request";
|
||||||
@ -117,12 +117,6 @@ export class Cluster implements ClusterModel, ClusterState {
|
|||||||
* @observable
|
* @observable
|
||||||
*/
|
*/
|
||||||
@observable disconnected = true;
|
@observable disconnected = true;
|
||||||
/**
|
|
||||||
* Connection failure reason
|
|
||||||
*
|
|
||||||
* @observable
|
|
||||||
*/
|
|
||||||
@observable failureReason: string;
|
|
||||||
/**
|
/**
|
||||||
* Does user have admin like access
|
* Does user have admin like access
|
||||||
*
|
*
|
||||||
@ -358,14 +352,20 @@ export class Cluster implements ClusterModel, ClusterState {
|
|||||||
if (this.disconnected || !this.accessible) {
|
if (this.disconnected || !this.accessible) {
|
||||||
await this.reconnect();
|
await this.reconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.broadcastConnectUpdate("Refreshing connection status ...");
|
||||||
await this.refreshConnectionStatus();
|
await this.refreshConnectionStatus();
|
||||||
|
|
||||||
if (this.accessible) {
|
if (this.accessible) {
|
||||||
|
this.broadcastConnectUpdate("Refreshing cluster accessibility ...");
|
||||||
await this.refreshAccessibility();
|
await this.refreshAccessibility();
|
||||||
this.ensureKubectl(); // download kubectl in background, so it's not blocking dashboard
|
// download kubectl in background, so it's not blocking dashboard
|
||||||
|
this.ensureKubectl()
|
||||||
|
.catch(error => logger.warn(`[CLUSTER]: failed to download kubectl for clusterId=${this.id}`, error));
|
||||||
|
this.broadcastConnectUpdate("Connected, waiting for view to load ...");
|
||||||
}
|
}
|
||||||
this.activated = true;
|
|
||||||
|
|
||||||
|
this.activated = true;
|
||||||
this.pushState();
|
this.pushState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -445,9 +445,8 @@ export class Cluster implements ClusterModel, ClusterState {
|
|||||||
private async refreshAccessibility(): Promise<void> {
|
private async refreshAccessibility(): Promise<void> {
|
||||||
this.isAdmin = await this.isClusterAdmin();
|
this.isAdmin = await this.isClusterAdmin();
|
||||||
this.isGlobalWatchEnabled = await this.canUseWatchApi({ resource: "*" });
|
this.isGlobalWatchEnabled = await this.canUseWatchApi({ resource: "*" });
|
||||||
|
this.allowedNamespaces = await this.getAllowedNamespaces();
|
||||||
await this.refreshAllowedResources();
|
this.allowedResources = await this.getAllowedResources();
|
||||||
|
|
||||||
this.ready = true;
|
this.ready = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -462,15 +461,6 @@ export class Cluster implements ClusterModel, ClusterState {
|
|||||||
this.accessible = connectionStatus == ClusterStatus.AccessGranted;
|
this.accessible = connectionStatus == ClusterStatus.AccessGranted;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal
|
|
||||||
*/
|
|
||||||
@action
|
|
||||||
async refreshAllowedResources() {
|
|
||||||
this.allowedNamespaces = await this.getAllowedNamespaces();
|
|
||||||
this.allowedResources = await this.getAllowedResources();
|
|
||||||
}
|
|
||||||
|
|
||||||
async getKubeconfig(): Promise<KubeConfig> {
|
async getKubeconfig(): Promise<KubeConfig> {
|
||||||
const { config } = await loadConfigFromFile(this.kubeConfigPath);
|
const { config } = await loadConfigFromFile(this.kubeConfigPath);
|
||||||
|
|
||||||
@ -501,34 +491,35 @@ export class Cluster implements ClusterModel, ClusterState {
|
|||||||
|
|
||||||
this.metadata.version = versionData.value;
|
this.metadata.version = versionData.value;
|
||||||
|
|
||||||
this.failureReason = null;
|
|
||||||
|
|
||||||
return ClusterStatus.AccessGranted;
|
return ClusterStatus.AccessGranted;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to connect cluster "${this.contextName}": ${error}`);
|
logger.error(`[CLUSTER]: Failed to connect to "${this.contextName}": ${error}`);
|
||||||
|
|
||||||
if (error.statusCode) {
|
if (error.statusCode) {
|
||||||
if (error.statusCode >= 400 && error.statusCode < 500) {
|
if (error.statusCode >= 400 && error.statusCode < 500) {
|
||||||
this.failureReason = "Invalid credentials";
|
this.broadcastConnectUpdate("Invalid credentials", true);
|
||||||
|
|
||||||
return ClusterStatus.AccessDenied;
|
|
||||||
} else {
|
|
||||||
this.failureReason = error.error || error.message;
|
|
||||||
|
|
||||||
return ClusterStatus.Offline;
|
|
||||||
}
|
|
||||||
} else if (error.failed === true) {
|
|
||||||
if (error.timedOut === true) {
|
|
||||||
this.failureReason = "Connection timed out";
|
|
||||||
|
|
||||||
return ClusterStatus.Offline;
|
|
||||||
} else {
|
|
||||||
this.failureReason = "Failed to fetch credentials";
|
|
||||||
|
|
||||||
return ClusterStatus.AccessDenied;
|
return ClusterStatus.AccessDenied;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.broadcastConnectUpdate(error.error || error.message, true);
|
||||||
|
|
||||||
|
return ClusterStatus.Offline;
|
||||||
}
|
}
|
||||||
this.failureReason = error.message;
|
|
||||||
|
if (error.failed === true) {
|
||||||
|
if (error.timedOut === true) {
|
||||||
|
this.broadcastConnectUpdate("Connection timed out", true);
|
||||||
|
|
||||||
|
return ClusterStatus.Offline;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.broadcastConnectUpdate("Failed to fetch credentials", true);
|
||||||
|
|
||||||
|
return ClusterStatus.AccessDenied;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.broadcastConnectUpdate(error.message, true);
|
||||||
|
|
||||||
return ClusterStatus.Offline;
|
return ClusterStatus.Offline;
|
||||||
}
|
}
|
||||||
@ -579,7 +570,7 @@ export class Cluster implements ClusterModel, ClusterState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toJSON(): ClusterModel {
|
toJSON(): ClusterModel {
|
||||||
const model: ClusterModel = {
|
return toJS({
|
||||||
id: this.id,
|
id: this.id,
|
||||||
contextName: this.contextName,
|
contextName: this.contextName,
|
||||||
kubeConfigPath: this.kubeConfigPath,
|
kubeConfigPath: this.kubeConfigPath,
|
||||||
@ -589,29 +580,24 @@ export class Cluster implements ClusterModel, ClusterState {
|
|||||||
metadata: this.metadata,
|
metadata: this.metadata,
|
||||||
accessibleNamespaces: this.accessibleNamespaces,
|
accessibleNamespaces: this.accessibleNamespaces,
|
||||||
labels: this.labels,
|
labels: this.labels,
|
||||||
};
|
});
|
||||||
|
|
||||||
return toJS(model);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serializable cluster-state used for sync btw main <-> renderer
|
* Serializable cluster-state used for sync btw main <-> renderer
|
||||||
*/
|
*/
|
||||||
getState(): ClusterState {
|
getState(): ClusterState {
|
||||||
const state: ClusterState = {
|
return toJS({
|
||||||
apiUrl: this.apiUrl,
|
apiUrl: this.apiUrl,
|
||||||
online: this.online,
|
online: this.online,
|
||||||
ready: this.ready,
|
ready: this.ready,
|
||||||
disconnected: this.disconnected,
|
disconnected: this.disconnected,
|
||||||
accessible: this.accessible,
|
accessible: this.accessible,
|
||||||
failureReason: this.failureReason,
|
|
||||||
isAdmin: this.isAdmin,
|
isAdmin: this.isAdmin,
|
||||||
allowedNamespaces: this.allowedNamespaces,
|
allowedNamespaces: this.allowedNamespaces,
|
||||||
allowedResources: this.allowedResources,
|
allowedResources: this.allowedResources,
|
||||||
isGlobalWatchEnabled: this.isGlobalWatchEnabled,
|
isGlobalWatchEnabled: this.isGlobalWatchEnabled,
|
||||||
};
|
});
|
||||||
|
|
||||||
return toJS(state);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -643,6 +629,17 @@ export class Cluster implements ClusterModel, ClusterState {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* broadcast an authentication update concerning this cluster
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
broadcastConnectUpdate(message: string, isError = false): void {
|
||||||
|
const update: KubeAuthUpdate = { message, isError };
|
||||||
|
|
||||||
|
logger.debug(`[CLUSTER]: broadcasting connection update`, { ...update, meta: this.getMeta() });
|
||||||
|
broadcastMessage(`cluster:${this.id}:connection-update`, update);
|
||||||
|
}
|
||||||
|
|
||||||
protected async getAllowedNamespaces() {
|
protected async getAllowedNamespaces() {
|
||||||
if (this.accessibleNamespaces.length) {
|
if (this.accessibleNamespaces.length) {
|
||||||
return this.accessibleNamespaces;
|
return this.accessibleNamespaces;
|
||||||
|
|||||||
@ -146,7 +146,7 @@ export class ContextHandler {
|
|||||||
proxyEnv.HTTPS_PROXY = this.cluster.preferences.httpsProxy;
|
proxyEnv.HTTPS_PROXY = this.cluster.preferences.httpsProxy;
|
||||||
}
|
}
|
||||||
this.kubeAuthProxy = new KubeAuthProxy(this.cluster, proxyEnv);
|
this.kubeAuthProxy = new KubeAuthProxy(this.cluster, proxyEnv);
|
||||||
this.kubeAuthProxy.run();
|
await this.kubeAuthProxy.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.kubeAuthProxy.whenReady;
|
await this.kubeAuthProxy.whenReady;
|
||||||
@ -157,8 +157,4 @@ export class ContextHandler {
|
|||||||
this.kubeAuthProxy = undefined;
|
this.kubeAuthProxy = undefined;
|
||||||
this.apiTarget = undefined;
|
this.apiTarget = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
get proxyLastError(): string {
|
|
||||||
return this.kubeAuthProxy?.lastError || "";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,7 +22,6 @@
|
|||||||
import { ChildProcess, spawn } from "child_process";
|
import { ChildProcess, spawn } from "child_process";
|
||||||
import { waitUntilUsed } from "tcp-port-used";
|
import { waitUntilUsed } from "tcp-port-used";
|
||||||
import { randomBytes } from "crypto";
|
import { randomBytes } from "crypto";
|
||||||
import { broadcastMessage } from "../common/ipc";
|
|
||||||
import type { Cluster } from "./cluster";
|
import type { Cluster } from "./cluster";
|
||||||
import { Kubectl } from "./kubectl";
|
import { Kubectl } from "./kubectl";
|
||||||
import logger from "./logger";
|
import logger from "./logger";
|
||||||
@ -30,22 +29,16 @@ import * as url from "url";
|
|||||||
import { getPortFrom } from "./utils/get-port";
|
import { getPortFrom } from "./utils/get-port";
|
||||||
import { makeObservable, observable, when } from "mobx";
|
import { makeObservable, observable, when } from "mobx";
|
||||||
|
|
||||||
export interface KubeAuthProxyLog {
|
|
||||||
data: string;
|
|
||||||
error?: boolean; // stream=stderr
|
|
||||||
}
|
|
||||||
|
|
||||||
const startingServeRegex = /^starting to serve on (?<address>.+)/i;
|
const startingServeRegex = /^starting to serve on (?<address>.+)/i;
|
||||||
|
|
||||||
export class KubeAuthProxy {
|
export class KubeAuthProxy {
|
||||||
public lastError: string;
|
|
||||||
public readonly apiPrefix: string;
|
public readonly apiPrefix: string;
|
||||||
|
|
||||||
public get port(): number {
|
public get port(): number {
|
||||||
return this._port;
|
return this._port;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _port: number;
|
protected _port?: number;
|
||||||
protected cluster: Cluster;
|
protected cluster: Cluster;
|
||||||
protected env: NodeJS.ProcessEnv = null;
|
protected env: NodeJS.ProcessEnv = null;
|
||||||
protected proxyProcess: ChildProcess;
|
protected proxyProcess: ChildProcess;
|
||||||
@ -92,67 +85,56 @@ export class KubeAuthProxy {
|
|||||||
|
|
||||||
this.proxyProcess = spawn(proxyBin, args, { env: this.env });
|
this.proxyProcess = spawn(proxyBin, args, { env: this.env });
|
||||||
this.proxyProcess.on("error", (error) => {
|
this.proxyProcess.on("error", (error) => {
|
||||||
this.sendIpcLogMessage({ data: error.message, error: true });
|
this.cluster.broadcastConnectUpdate(error.message, true);
|
||||||
this.exit();
|
this.exit();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.proxyProcess.on("exit", (code) => {
|
this.proxyProcess.on("exit", (code) => {
|
||||||
this.sendIpcLogMessage({ data: `proxy exited with code: ${code}`, error: code > 0 });
|
this.cluster.broadcastConnectUpdate(`proxy exited with code: ${code}`, code > 0);
|
||||||
|
this.exit();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.proxyProcess.on("disconnect", () => {
|
||||||
|
this.cluster.broadcastConnectUpdate("Proxy disconnected communications", true );
|
||||||
this.exit();
|
this.exit();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.proxyProcess.stderr.on("data", (data) => {
|
this.proxyProcess.stderr.on("data", (data) => {
|
||||||
this.lastError = this.parseError(data.toString());
|
this.cluster.broadcastConnectUpdate(data.toString(), true);
|
||||||
this.sendIpcLogMessage({ data: data.toString(), error: true });
|
});
|
||||||
|
|
||||||
|
this.proxyProcess.stdout.on("data", (data: any) => {
|
||||||
|
if (typeof this._port === "number") {
|
||||||
|
this.cluster.broadcastConnectUpdate(data.toString());
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this._port = await getPortFrom(this.proxyProcess.stdout, {
|
this._port = await getPortFrom(this.proxyProcess.stdout, {
|
||||||
lineRegex: startingServeRegex,
|
lineRegex: startingServeRegex,
|
||||||
onFind: () => this.sendIpcLogMessage({ data: "Authentication proxy started\n" }),
|
onFind: () => this.cluster.broadcastConnectUpdate("Authentication proxy started"),
|
||||||
});
|
});
|
||||||
|
|
||||||
this.proxyProcess.stdout.on("data", (data: any) => {
|
try {
|
||||||
this.sendIpcLogMessage({ data: data.toString() });
|
await waitUntilUsed(this.port, 500, 10000);
|
||||||
});
|
this.ready = true;
|
||||||
|
} catch (error) {
|
||||||
|
this.cluster.broadcastConnectUpdate("Proxy port failed to be used within timelimit, restarting...", true);
|
||||||
|
this.exit();
|
||||||
|
|
||||||
await waitUntilUsed(this.port, 500, 10000);
|
return this.run();
|
||||||
|
|
||||||
this.ready = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected parseError(data: string) {
|
|
||||||
const error = data.split("http: proxy error:").slice(1).join("").trim();
|
|
||||||
let errorMsg = error;
|
|
||||||
const jsonError = error.split("Response: ")[1];
|
|
||||||
|
|
||||||
if (jsonError) {
|
|
||||||
try {
|
|
||||||
const parsedError = JSON.parse(jsonError);
|
|
||||||
|
|
||||||
errorMsg = parsedError.error_description || parsedError.error || jsonError;
|
|
||||||
} catch (_) {
|
|
||||||
errorMsg = jsonError.trim();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return errorMsg;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected sendIpcLogMessage(res: KubeAuthProxyLog) {
|
|
||||||
const channel = `kube-auth:${this.cluster.id}`;
|
|
||||||
|
|
||||||
logger.info(`[KUBE-AUTH]: out-channel "${channel}"`, { ...res, meta: this.cluster.getMeta() });
|
|
||||||
broadcastMessage(channel, res);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public exit() {
|
public exit() {
|
||||||
this.ready = false;
|
this.ready = false;
|
||||||
if (!this.proxyProcess) return;
|
|
||||||
logger.debug("[KUBE-AUTH]: stopping local proxy", this.cluster.getMeta());
|
if (this.proxyProcess) {
|
||||||
this.proxyProcess.kill();
|
logger.debug("[KUBE-AUTH]: stopping local proxy", this.cluster.getMeta());
|
||||||
this.proxyProcess.removeAllListeners();
|
this.proxyProcess.removeAllListeners();
|
||||||
this.proxyProcess.stderr.removeAllListeners();
|
this.proxyProcess.stderr.removeAllListeners();
|
||||||
this.proxyProcess.stdout.removeAllListeners();
|
this.proxyProcess.stdout.removeAllListeners();
|
||||||
this.proxyProcess = null;
|
this.proxyProcess.kill();
|
||||||
|
this.proxyProcess = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -49,7 +49,7 @@ export class KubeconfigManager {
|
|||||||
try {
|
try {
|
||||||
this.tempFile = await this.createProxyKubeconfig();
|
this.tempFile = await this.createProxyKubeconfig();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(`Failed to created temp config for auth-proxy`, { err });
|
logger.error(`[KUBECONFIG-MANAGER]: Failed to created temp config for auth-proxy`, { err });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,7 +61,7 @@ export class KubeconfigManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Deleting temporary kubeconfig: ${this.tempFile}`);
|
logger.info(`[KUBECONFIG-MANAGER]: Deleting temporary kubeconfig: ${this.tempFile}`);
|
||||||
await fs.unlink(this.tempFile);
|
await fs.unlink(this.tempFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,7 +70,7 @@ export class KubeconfigManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Deleting temporary kubeconfig: ${this.tempFile}`);
|
logger.info(`[KUBECONFIG-MANAGER]: Deleting temporary kubeconfig: ${this.tempFile}`);
|
||||||
await fs.unlink(this.tempFile);
|
await fs.unlink(this.tempFile);
|
||||||
this.tempFile = undefined;
|
this.tempFile = undefined;
|
||||||
}
|
}
|
||||||
@ -80,8 +80,7 @@ export class KubeconfigManager {
|
|||||||
await this.contextHandler.ensureServer();
|
await this.contextHandler.ensureServer();
|
||||||
this.tempFile = await this.createProxyKubeconfig();
|
this.tempFile = await this.createProxyKubeconfig();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err);
|
logger.error(`[KUBECONFIG-MANAGER]: Failed to created temp config for auth-proxy`, err);
|
||||||
logger.error(`Failed to created temp config for auth-proxy`, { err });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -124,7 +123,7 @@ export class KubeconfigManager {
|
|||||||
|
|
||||||
await fs.ensureDir(path.dirname(tempFile));
|
await fs.ensureDir(path.dirname(tempFile));
|
||||||
await fs.writeFile(tempFile, configYaml, { mode: 0o600 });
|
await fs.writeFile(tempFile, configYaml, { mode: 0o600 });
|
||||||
logger.debug(`Created temp kubeconfig "${contextName}" at "${tempFile}": \n${configYaml}`);
|
logger.debug(`[KUBECONFIG-MANAGER]: Created temp kubeconfig "${contextName}" at "${tempFile}": \n${configYaml}`);
|
||||||
|
|
||||||
return tempFile;
|
return tempFile;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -119,7 +119,7 @@ export class WindowManager extends Singleton {
|
|||||||
logger.info("[WINDOW-MANAGER]: Main window loaded");
|
logger.info("[WINDOW-MANAGER]: Main window loaded");
|
||||||
})
|
})
|
||||||
.on("will-attach-webview", (event, webPreferences, params) => {
|
.on("will-attach-webview", (event, webPreferences, params) => {
|
||||||
logger.info("[WINDOW-MANAGER]: Attaching webview");
|
logger.debug("[WINDOW-MANAGER]: Attaching webview");
|
||||||
// Following is security recommendations because we allow webview tag (webviewTag: true)
|
// Following is security recommendations because we allow webview tag (webviewTag: true)
|
||||||
// suggested by https://www.electronjs.org/docs/tutorial/security#11-verify-webview-options-before-creation
|
// suggested by https://www.electronjs.org/docs/tutorial/security#11-verify-webview-options-before-creation
|
||||||
// and https://www.electronjs.org/docs/tutorial/security#10-do-not-use-allowpopups
|
// and https://www.electronjs.org/docs/tutorial/security#10-do-not-use-allowpopups
|
||||||
|
|||||||
@ -21,9 +21,8 @@
|
|||||||
|
|
||||||
import styles from "./cluster-status.module.css";
|
import styles from "./cluster-status.module.css";
|
||||||
|
|
||||||
import { ipcRenderer } from "electron";
|
|
||||||
import { computed, observable, makeObservable } from "mobx";
|
import { computed, observable, makeObservable } from "mobx";
|
||||||
import { observer } from "mobx-react";
|
import { disposeOnUnmount, observer } from "mobx-react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { clusterActivateHandler } from "../../../common/cluster-ipc";
|
import { clusterActivateHandler } from "../../../common/cluster-ipc";
|
||||||
import { ClusterStore } from "../../../common/cluster-store";
|
import { ClusterStore } from "../../../common/cluster-store";
|
||||||
@ -33,10 +32,9 @@ import { cssNames, IClassName } from "../../utils";
|
|||||||
import { Button } from "../button";
|
import { Button } from "../button";
|
||||||
import { Icon } from "../icon";
|
import { Icon } from "../icon";
|
||||||
import { Spinner } from "../spinner";
|
import { Spinner } from "../spinner";
|
||||||
import type { KubeAuthProxyLog } from "../../../main/kube-auth-proxy";
|
|
||||||
import { navigate } from "../../navigation";
|
import { navigate } from "../../navigation";
|
||||||
import { entitySettingsURL } from "../../../common/routes";
|
import { entitySettingsURL } from "../../../common/routes";
|
||||||
import type { ClusterId } from "../../../common/cluster-types";
|
import type { ClusterId, KubeAuthUpdate } from "../../../common/cluster-types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
className?: IClassName;
|
className?: IClassName;
|
||||||
@ -45,7 +43,7 @@ interface Props {
|
|||||||
|
|
||||||
@observer
|
@observer
|
||||||
export class ClusterStatus extends React.Component<Props> {
|
export class ClusterStatus extends React.Component<Props> {
|
||||||
@observable authOutput: KubeAuthProxyLog[] = [];
|
@observable authOutput: KubeAuthUpdate[] = [];
|
||||||
@observable isReconnecting = false;
|
@observable isReconnecting = false;
|
||||||
|
|
||||||
constructor(props: Props) {
|
constructor(props: Props) {
|
||||||
@ -58,31 +56,31 @@ export class ClusterStatus extends React.Component<Props> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@computed get hasErrors(): boolean {
|
@computed get hasErrors(): boolean {
|
||||||
return this.authOutput.some(({ error }) => error) || !!this.cluster.failureReason;
|
return this.authOutput.some(({ isError }) => isError);
|
||||||
}
|
}
|
||||||
|
|
||||||
async componentDidMount() {
|
componentDidMount() {
|
||||||
ipcRendererOn(`kube-auth:${this.cluster.id}`, (evt, res: KubeAuthProxyLog) => {
|
disposeOnUnmount(this, [
|
||||||
this.authOutput.push({
|
ipcRendererOn(`cluster:${this.cluster.id}:connection-update`, (evt, res: KubeAuthUpdate) => {
|
||||||
data: res.data.trimRight(),
|
this.authOutput.push(res);
|
||||||
error: res.error,
|
}),
|
||||||
});
|
]);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
ipcRenderer.removeAllListeners(`kube-auth:${this.props.clusterId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
activateCluster = async (force = false) => {
|
|
||||||
await requestMain(clusterActivateHandler, this.props.clusterId, force);
|
|
||||||
};
|
|
||||||
|
|
||||||
reconnect = async () => {
|
reconnect = async () => {
|
||||||
this.authOutput = [];
|
this.authOutput = [];
|
||||||
this.isReconnecting = true;
|
this.isReconnecting = true;
|
||||||
await this.activateCluster(true);
|
|
||||||
this.isReconnecting = false;
|
try {
|
||||||
|
await requestMain(clusterActivateHandler, this.props.clusterId, true);
|
||||||
|
} catch (error) {
|
||||||
|
this.authOutput.push({
|
||||||
|
message: error.toString(),
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.isReconnecting = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
manageProxySettings = () => {
|
manageProxySettings = () => {
|
||||||
@ -94,58 +92,68 @@ export class ClusterStatus extends React.Component<Props> {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
renderContent() {
|
renderAuthenticationOutput() {
|
||||||
const { authOutput, cluster, hasErrors } = this;
|
return (
|
||||||
const failureReason = cluster.failureReason;
|
<pre>
|
||||||
|
{
|
||||||
|
this.authOutput.map(({ message, isError }, index) => (
|
||||||
|
<p key={index} className={cssNames({ error: isError })}>
|
||||||
|
{message.trim()}
|
||||||
|
</p>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!hasErrors || this.isReconnecting) {
|
renderStatusIcon() {
|
||||||
return (
|
if (this.hasErrors) {
|
||||||
<div className="flex items-center column gaps">
|
return <Icon material="cloud_off" className={styles.icon} />;
|
||||||
<Spinner singleColor={false} className={styles.spinner} />
|
|
||||||
<pre className="kube-auth-out">
|
|
||||||
<p>{this.isReconnecting ? "Reconnecting" : "Connecting"}…</p>
|
|
||||||
{authOutput.map(({ data, error }, index) => {
|
|
||||||
return <p key={index} className={cssNames({ error })}>{data}</p>;
|
|
||||||
})}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center column gaps">
|
<>
|
||||||
<Icon material="cloud_off" className={styles.icon} />
|
<Spinner singleColor={false} className={styles.spinner} />
|
||||||
<h2>
|
<pre className="kube-auth-out">
|
||||||
{cluster.preferences.clusterName}
|
<p>{this.isReconnecting ? "Reconnecting" : "Connecting"}…</p>
|
||||||
</h2>
|
|
||||||
<pre>
|
|
||||||
{authOutput.map(({ data, error }, index) => {
|
|
||||||
return <p key={index} className={cssNames({ error })}>{data}</p>;
|
|
||||||
})}
|
|
||||||
</pre>
|
</pre>
|
||||||
{failureReason && (
|
</>
|
||||||
<div className="error">{failureReason}</div>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
primary
|
|
||||||
label="Reconnect"
|
|
||||||
className="box center"
|
|
||||||
onClick={this.reconnect}
|
|
||||||
waiting={this.isReconnecting}
|
|
||||||
/>
|
|
||||||
<a
|
|
||||||
className="box center interactive"
|
|
||||||
onClick={this.manageProxySettings}>
|
|
||||||
Manage Proxy Settings
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderReconnectionHelp() {
|
||||||
|
if (this.hasErrors && !this.isReconnecting) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
primary
|
||||||
|
label="Reconnect"
|
||||||
|
className="box center"
|
||||||
|
onClick={this.reconnect}
|
||||||
|
waiting={this.isReconnecting}
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
className="box center interactive"
|
||||||
|
onClick={this.manageProxySettings}
|
||||||
|
>
|
||||||
|
Manage Proxy Settings
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<div className={cssNames(styles.status, "flex column box center align-center justify-center", this.props.className)}>
|
<div className={cssNames(styles.status, "flex column box center align-center justify-center", this.props.className)}>
|
||||||
{this.renderContent()}
|
<div className="flex items-center column gaps">
|
||||||
|
<h2>{this.cluster.preferences.clusterName}</h2>
|
||||||
|
{this.renderStatusIcon()}
|
||||||
|
{this.renderAuthenticationOutput()}
|
||||||
|
{this.renderReconnectionHelp()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user