From 998f7aa934b4454b43d1349885c774740c014241 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Fri, 30 Apr 2021 09:48:20 -0400 Subject: [PATCH] Add the ability to sync kube config files (#2567) * Add the ability to sync kube config files - Will update when the files changes - add KUBECONFIG_SYNC label - fix rebase and change to addObservableSource - move UI to user settings - support shallow folder watching - add some unit tests for the diff-er Signed-off-by: Sebastian Malton * responding to review comments Signed-off-by: Sebastian Malton * fix tests and add try/catch Signed-off-by: Sebastian Malton * always sync c&p folder, remove bad rebase Signed-off-by: Sebastian Malton * fix preferences Signed-off-by: Sebastian Malton * Fix settings saving and catalog view Signed-off-by: Sebastian Malton * fix unit tests Signed-off-by: Sebastian Malton * fix synced clusters not connectable Signed-off-by: Sebastian Malton * change to non-complete shallow watching Signed-off-by: Sebastian Malton * fix sizing Signed-off-by: Sebastian Malton * Catch readStream errors Signed-off-by: Sebastian Malton * don't clear UserStore on non-existant preference field, fix unlinking not removing items from source Signed-off-by: Sebastian Malton * change label to file Signed-off-by: Sebastian Malton --- integration/helpers/minikube.ts | 3 + package.json | 5 +- .../__tests__/catalog-entity-registry.test.ts | 6 +- src/common/__tests__/kube-helpers.test.ts | 40 +-- src/common/__tests__/user-store.test.ts | 5 +- .../catalog-entities/kubernetes-cluster.ts | 2 +- src/common/catalog/catalog-entity-registry.ts | 13 +- src/common/catalog/catalog-entity.ts | 2 +- src/common/cluster-store.ts | 22 +- src/common/kube-helpers.ts | 112 +++++--- src/common/user-store.ts | 90 ++++--- src/common/utils/extended-map.ts | 64 +++++ src/common/utils/index.ts | 1 + src/common/utils/iter.ts | 64 +++++ src/extensions/lens-main-extension.ts | 2 +- src/main/catalog-pusher.ts | 17 +- .../__test__/kubeconfig-sync.test.ts | 252 ++++++++++++++++++ src/main/catalog-sources/index.ts | 1 + src/main/catalog-sources/kubeconfig-sync.ts | 251 +++++++++++++++++ src/main/cluster-manager.ts | 78 +++--- src/main/cluster.ts | 47 +++- src/main/index.ts | 5 + .../__tests__/catalog-entity-registry.test.ts | 21 -- src/renderer/api/catalog-entity-registry.ts | 22 +- src/renderer/bootstrap.tsx | 3 +- .../components/+add-cluster/add-cluster.tsx | 32 +-- src/renderer/components/+catalog/catalog.scss | 19 ++ .../+preferences/kubeconfig-syncs.tsx | 176 ++++++++++++ .../components/+preferences/preferences.scss | 32 ++- .../components/+preferences/preferences.tsx | 9 +- src/renderer/mui-base-theme.tsx | 34 +++ yarn.lock | 21 +- 32 files changed, 1215 insertions(+), 236 deletions(-) create mode 100644 src/common/utils/extended-map.ts create mode 100644 src/main/catalog-sources/__test__/kubeconfig-sync.test.ts create mode 100644 src/main/catalog-sources/index.ts create mode 100644 src/main/catalog-sources/kubeconfig-sync.ts create mode 100644 src/renderer/components/+preferences/kubeconfig-syncs.tsx create mode 100644 src/renderer/mui-base-theme.tsx diff --git a/integration/helpers/minikube.ts b/integration/helpers/minikube.ts index da42b474cb..edbe3127d1 100644 --- a/integration/helpers/minikube.ts +++ b/integration/helpers/minikube.ts @@ -53,6 +53,9 @@ export async function addMinikubeCluster(app: Application) { await app.client.click("div.Select__control"); // hide the context drop-down list (it might be obscuring the Add cluster(s) button) await app.client.click("button.primary"); // add minikube cluster await app.client.waitUntilTextExists("div.TableCell", "minikube"); + await app.client.waitForExist(".Input.SearchInput input"); + await app.client.setValue(".Input.SearchInput input", "minikube"); + await app.client.waitUntilTextExists("div.TableCell", "minikube"); await app.client.click("div.TableRow"); } diff --git a/package.json b/package.json index 20aa9b4c20..528f74c084 100644 --- a/package.json +++ b/package.json @@ -237,6 +237,7 @@ "devDependencies": { "@emeraldpay/hashicon-react": "^0.4.0", "@material-ui/core": "^4.10.1", + "@material-ui/icons": "^4.11.2", "@material-ui/lab": "^4.0.0-alpha.57", "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3", "@testing-library/jest-dom": "^5.11.10", @@ -254,7 +255,7 @@ "@types/hoist-non-react-statics": "^3.3.1", "@types/html-webpack-plugin": "^3.2.3", "@types/http-proxy": "^1.17.5", - "@types/jest": "^25.2.3", + "@types/jest": "^26.0.22", "@types/js-yaml": "^3.12.4", "@types/jsdom": "^16.2.4", "@types/jsonpath": "^0.2.0", @@ -344,7 +345,7 @@ "sharp": "^0.26.1", "spectron": "11.0.0", "style-loader": "^1.2.1", - "ts-jest": "^26.1.0", + "ts-jest": "26.3.0", "ts-loader": "^7.0.5", "ts-node": "^8.10.2", "type-fest": "^1.0.2", diff --git a/src/common/__tests__/catalog-entity-registry.test.ts b/src/common/__tests__/catalog-entity-registry.test.ts index 1a53865241..50bdaa0b1e 100644 --- a/src/common/__tests__/catalog-entity-registry.test.ts +++ b/src/common/__tests__/catalog-entity-registry.test.ts @@ -27,7 +27,7 @@ describe("CatalogEntityRegistry", () => { it ("allows to add an observable source", () => { const source = observable.array([]); - registry.addSource("test", source); + registry.addObservableSource("test", source); expect(registry.items.length).toEqual(0); source.push(entity); @@ -38,7 +38,7 @@ describe("CatalogEntityRegistry", () => { it ("added source change triggers reaction", (done) => { const source = observable.array([]); - registry.addSource("test", source); + registry.addObservableSource("test", source); reaction(() => registry.items, () => { done(); }); @@ -51,7 +51,7 @@ describe("CatalogEntityRegistry", () => { it ("removes source", () => { const source = observable.array([]); - registry.addSource("test", source); + registry.addObservableSource("test", source); source.push(entity); registry.removeSource("test"); diff --git a/src/common/__tests__/kube-helpers.test.ts b/src/common/__tests__/kube-helpers.test.ts index 2a346bfef5..808092c89d 100644 --- a/src/common/__tests__/kube-helpers.test.ts +++ b/src/common/__tests__/kube-helpers.test.ts @@ -72,55 +72,63 @@ describe("kube helpers", () => { }); describe("with default validation options", () => { describe("with valid kubeconfig", () => { - it("does not raise exceptions", () => { - expect(() => { validateKubeConfig(kc, "valid");}).not.toThrow(); + it("does not return an error", () => { + expect(validateKubeConfig(kc, "valid")).toBeUndefined(); }); }); describe("with invalid context object", () => { - it("it raises exception", () => { - expect(() => { validateKubeConfig(kc, "invalid");}).toThrow("No valid context object provided in kubeconfig for context 'invalid'"); + it("returns an error", () => { + expect(String(validateKubeConfig(kc, "invalid"))).toEqual( + expect.stringContaining("No valid context object provided in kubeconfig for context 'invalid'") + ); }); }); describe("with invalid cluster object", () => { - it("it raises exception", () => { - expect(() => { validateKubeConfig(kc, "invalidCluster");}).toThrow("No valid cluster object provided in kubeconfig for context 'invalidCluster'"); + it("returns an error", () => { + expect(String(validateKubeConfig(kc, "invalidCluster"))).toEqual( + expect.stringContaining("No valid cluster object provided in kubeconfig for context 'invalidCluster'") + ); }); }); describe("with invalid user object", () => { - it("it raises exception", () => { - expect(() => { validateKubeConfig(kc, "invalidUser");}).toThrow("No valid user object provided in kubeconfig for context 'invalidUser'"); + it("returns an error", () => { + expect(String(validateKubeConfig(kc, "invalidUser"))).toEqual( + expect.stringContaining("No valid user object provided in kubeconfig for context 'invalidUser'") + ); }); }); describe("with invalid exec command", () => { - it("it raises exception", () => { - expect(() => { validateKubeConfig(kc, "invalidExec");}).toThrow("User Exec command \"foo\" not found on host. Please ensure binary is found in PATH or use absolute path to binary in Kubeconfig"); + it("returns an error", () => { + expect(String(validateKubeConfig(kc, "invalidExec"))).toEqual( + expect.stringContaining("User Exec command \"foo\" not found on host. Please ensure binary is found in PATH or use absolute path to binary in Kubeconfig") + ); }); }); }); describe("with validateCluster as false", () => { describe("with invalid cluster object", () => { - it("does not raise exception", () => { - expect(() => { validateKubeConfig(kc, "invalidCluster", { validateCluster: false });}).not.toThrow(); + it("does not return an error", () => { + expect(validateKubeConfig(kc, "invalidCluster", { validateCluster: false })).toBeUndefined(); }); }); }); describe("with validateUser as false", () => { describe("with invalid user object", () => { - it("does not raise exceptions", () => { - expect(() => { validateKubeConfig(kc, "invalidUser", { validateUser: false });}).not.toThrow(); + it("does not return an error", () => { + expect(validateKubeConfig(kc, "invalidUser", { validateUser: false })).toBeUndefined(); }); }); }); describe("with validateExec as false", () => { describe("with invalid exec object", () => { - it("does not raise exceptions", () => { - expect(() => { validateKubeConfig(kc, "invalidExec", { validateExec: false });}).not.toThrow(); + it("does not return an error", () => { + expect(validateKubeConfig(kc, "invalidExec", { validateExec: false })).toBeUndefined(); }); }); }); diff --git a/src/common/__tests__/user-store.test.ts b/src/common/__tests__/user-store.test.ts index 7e007efb0d..01edbda09d 100644 --- a/src/common/__tests__/user-store.test.ts +++ b/src/common/__tests__/user-store.test.ts @@ -19,12 +19,13 @@ import { UserStore } from "../user-store"; import { SemVer } from "semver"; import electron from "electron"; import { stdout, stderr } from "process"; +import { beforeEachWrapped } from "../../../integration/helpers/utils"; console = new Console(stdout, stderr); describe("user store tests", () => { describe("for an empty config", () => { - beforeEach(() => { + beforeEachWrapped(() => { UserStore.resetInstance(); mockFs({ tmp: { "config.json": "{}", "kube_config": "{}" } }); @@ -90,7 +91,7 @@ describe("user store tests", () => { }); describe("migrations", () => { - beforeEach(() => { + beforeEachWrapped(() => { UserStore.resetInstance(); mockFs({ "tmp": { diff --git a/src/common/catalog-entities/kubernetes-cluster.ts b/src/common/catalog-entities/kubernetes-cluster.ts index c2dae01b74..dc5159e509 100644 --- a/src/common/catalog-entities/kubernetes-cluster.ts +++ b/src/common/catalog-entities/kubernetes-cluster.ts @@ -94,7 +94,7 @@ export class KubernetesClusterCategory extends CatalogCategory { ctx.menuItems.push({ icon: "text_snippet", title: "Add from kubeconfig", - onClick: async () => { + onClick: () => { ctx.navigate("/add-cluster"); } }); diff --git a/src/common/catalog/catalog-entity-registry.ts b/src/common/catalog/catalog-entity-registry.ts index 28afa55bac..bdb5ed5bc4 100644 --- a/src/common/catalog/catalog-entity-registry.ts +++ b/src/common/catalog/catalog-entity-registry.ts @@ -1,10 +1,15 @@ -import { action, computed, observable, IObservableArray } from "mobx"; +import { action, computed, observable, IComputedValue, IObservableArray } from "mobx"; import { CatalogEntity } from "./catalog-entity"; +import { iter } from "../utils"; export class CatalogEntityRegistry { - protected sources = observable.map>([], { deep: true }); + protected sources = observable.map>([], { deep: true }); - @action addSource(id: string, source: IObservableArray) { + @action addObservableSource(id: string, source: IObservableArray) { + this.sources.set(id, computed(() => source)); + } + + @action addComputedSource(id: string, source: IComputedValue) { this.sources.set(id, source); } @@ -13,7 +18,7 @@ export class CatalogEntityRegistry { } @computed get items(): CatalogEntity[] { - return Array.from(this.sources.values()).flat(); + return Array.from(iter.flatMap(this.sources.values(), source => source.get())); } getItemsForApiKind(apiVersion: string, kind: string): T[] { diff --git a/src/common/catalog/catalog-entity.ts b/src/common/catalog/catalog-entity.ts index fdd443ae54..2062347f4a 100644 --- a/src/common/catalog/catalog-entity.ts +++ b/src/common/catalog/catalog-entity.ts @@ -65,7 +65,7 @@ export interface CatalogEntityContextMenu { icon: string; title: string; onlyVisibleForSource?: string; // show only if empty or if matches with entity source - onClick: () => Promise; + onClick: () => void | Promise; confirm?: { message: string; } diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index 57cf900f57..c5ebb45c85 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -37,6 +37,10 @@ export interface ClusterStoreModel { export type ClusterId = string; +export interface UpdateClusterModel extends Omit { + id?: ClusterId; +} + export interface ClusterModel { /** Unique id for a cluster */ id: ClusterId; @@ -94,8 +98,12 @@ export interface ClusterPrometheusPreferences { } export class ClusterStore extends BaseStore { + static get storedKubeConfigFolder(): string { + return path.resolve((app || remote.app).getPath("userData"), "kubeconfigs"); + } + static getCustomKubeConfigPath(clusterId: ClusterId): string { - return path.resolve((app || remote.app).getPath("userData"), "kubeconfigs", clusterId); + return path.resolve(ClusterStore.storedKubeConfigFolder, clusterId); } static embedCustomKubeConfig(clusterId: ClusterId, kubeConfig: KubeConfig | string): string { @@ -259,18 +267,18 @@ export class ClusterStore extends BaseStore { } @action - addCluster(model: ClusterModel | Cluster): Cluster { + addCluster(clusterOrModel: ClusterModel | Cluster): Cluster { appEventBus.emit({ name: "cluster", action: "add" }); - let cluster = model as Cluster; - if (!(model instanceof Cluster)) { - cluster = new Cluster(model); - } + const cluster = clusterOrModel instanceof Cluster + ? clusterOrModel + : new Cluster(clusterOrModel); if (!cluster.isManaged) { cluster.enabled = true; } - this.clusters.set(model.id, cluster); + + this.clusters.set(cluster.id, cluster); return cluster; } diff --git a/src/common/kube-helpers.ts b/src/common/kube-helpers.ts index e4a1168699..35d32a3d8f 100644 --- a/src/common/kube-helpers.ts +++ b/src/common/kube-helpers.ts @@ -6,7 +6,7 @@ import yaml from "js-yaml"; import logger from "../main/logger"; import commandExists from "command-exists"; import { ExecValidationNotFoundError } from "./custom-errors"; -import { newClusters, newContexts, newUsers } from "@kubernetes/client-node/dist/config_types"; +import { Cluster, Context, newClusters, newContexts, newUsers, User } from "@kubernetes/client-node/dist/config_types"; export type KubeConfigValidationOpts = { validateCluster?: boolean; @@ -28,11 +28,26 @@ function readResolvedPathSync(filePath: string): string { return fse.readFileSync(path.resolve(resolveTilde(filePath)), "utf8"); } -function checkRawContext(rawContext: any): boolean { - return rawContext.name && rawContext.context?.cluster && rawContext.context?.user; +function checkRawCluster(rawCluster: any): boolean { + return Boolean(rawCluster?.name && rawCluster?.cluster?.server); } -function loadToOptions(rawYaml: string): any { +function checkRawUser(rawUser: any): boolean { + return Boolean(rawUser?.name); +} + +function checkRawContext(rawContext: any): boolean { + return Boolean(rawContext.name && rawContext.context?.cluster && rawContext.context?.user); +} + +export interface KubeConfigOptions { + clusters: Cluster[]; + users: User[]; + contexts: Context[]; + currentContext: string; +} + +function loadToOptions(rawYaml: string): KubeConfigOptions { const obj = yaml.safeLoad(rawYaml); if (typeof obj !== "object" || !obj) { @@ -40,16 +55,14 @@ function loadToOptions(rawYaml: string): any { } const { clusters: rawClusters, users: rawUsers, contexts: rawContexts, "current-context": currentContext } = obj; - const clusters = newClusters(rawClusters); - const users = newUsers(rawUsers); + const clusters = newClusters(rawClusters?.filter(checkRawCluster)); + const users = newUsers(rawUsers?.filter(checkRawUser)); const contexts = newContexts(rawContexts?.filter(checkRawContext)); return { clusters, users, contexts, currentContext }; } -export function loadConfig(pathOrContent?: string): KubeConfig { - const content = fse.pathExistsSync(pathOrContent) ? readResolvedPathSync(pathOrContent) : pathOrContent; - const options = loadToOptions(content); +export function loadFromOptions(options: KubeConfigOptions): KubeConfig { const kc = new KubeConfig(); // need to load using the kubernetes client to generate a kubeconfig object @@ -58,6 +71,18 @@ export function loadConfig(pathOrContent?: string): KubeConfig { return kc; } +export function loadConfig(pathOrContent?: string): KubeConfig { + return loadConfigFromString( + fse.pathExistsSync(pathOrContent) + ? readResolvedPathSync(pathOrContent) + : pathOrContent + ); +} + +export function loadConfigFromString(content: string): KubeConfig { + return loadFromOptions(loadToOptions(content)); +} + /** * KubeConfig is valid when there's at least one of each defined: * - User @@ -181,42 +206,47 @@ export function getNodeWarningConditions(node: V1Node) { /** * Checks if `config` has valid `Context`, `User`, `Cluster`, and `exec` fields (if present when required) + * + * Note: This function returns an error instead of throwing it, returning `undefined` if the validation passes */ -export function validateKubeConfig (config: KubeConfig, contextName: string, validationOpts: KubeConfigValidationOpts = {}) { - // we only receive a single context, cluster & user object here so lets validate them as this - // will be called when we add a new cluster to Lens +export function validateKubeConfig(config: KubeConfig, contextName: string, validationOpts: KubeConfigValidationOpts = {}): Error | undefined { + try { + // we only receive a single context, cluster & user object here so lets validate them as this + // will be called when we add a new cluster to Lens - const { validateUser = true, validateCluster = true, validateExec = true } = validationOpts; + const { validateUser = true, validateCluster = true, validateExec = true } = validationOpts; - const contextObject = config.getContextObject(contextName); + const contextObject = config.getContextObject(contextName); - // Validate the Context Object - if (!contextObject) { - throw new Error(`No valid context object provided in kubeconfig for context '${contextName}'`); - } - - // Validate the Cluster Object - if (validateCluster && !config.getCluster(contextObject.cluster)) { - throw new Error(`No valid cluster object provided in kubeconfig for context '${contextName}'`); - } - - const user = config.getUser(contextObject.user); - - // Validate the User Object - if (validateUser && !user) { - throw new Error(`No valid user object provided in kubeconfig for context '${contextName}'`); - } - - // Validate exec command if present - if (validateExec && user?.exec) { - const execCommand = user.exec["command"]; - // check if the command is absolute or not - const isAbsolute = path.isAbsolute(execCommand); - - // validate the exec struct in the user object, start with the command field - if (!commandExists.sync(execCommand)) { - logger.debug(`validateKubeConfig: exec command ${String(execCommand)} in kubeconfig ${contextName} not found`); - throw new ExecValidationNotFoundError(execCommand, isAbsolute); + // Validate the Context Object + if (!contextObject) { + return new Error(`No valid context object provided in kubeconfig for context '${contextName}'`); } + + // Validate the Cluster Object + if (validateCluster && !config.getCluster(contextObject.cluster)) { + return new Error(`No valid cluster object provided in kubeconfig for context '${contextName}'`); + } + + const user = config.getUser(contextObject.user); + + // Validate the User Object + if (validateUser && !user) { + return new Error(`No valid user object provided in kubeconfig for context '${contextName}'`); + } + + // Validate exec command if present + if (validateExec && user?.exec) { + const execCommand = user.exec["command"]; + // check if the command is absolute or not + const isAbsolute = path.isAbsolute(execCommand); + + // validate the exec struct in the user object, start with the command field + if (!commandExists.sync(execCommand)) { + return new ExecValidationNotFoundError(execCommand, isAbsolute); + } + } + } catch (error) { + return error; } } diff --git a/src/common/user-store.ts b/src/common/user-store.ts index 1118344d02..ab07838f9b 100644 --- a/src/common/user-store.ts +++ b/src/common/user-store.ts @@ -11,6 +11,7 @@ import { kubeConfigDefaultPath, loadConfig } from "./kube-helpers"; import { appEventBus } from "./event-bus"; import logger from "../main/logger"; import path from "path"; +import os from "os"; import { fileNameMigration } from "../migrations/user-store"; import { ObservableToggleSet } from "../renderer/utils"; @@ -21,6 +22,12 @@ export interface UserStoreModel { preferences: UserPreferencesModel; } +export interface KubeconfigSyncEntry extends KubeconfigSyncValue { + filePath: string; +} + +export interface KubeconfigSyncValue {} + export interface UserPreferencesModel { httpsProxy?: string; shell?: string; @@ -34,6 +41,7 @@ export interface UserPreferencesModel { kubectlBinariesPath?: string; openAtLogin?: boolean; hiddenTableColumns?: [string, string[]][]; + syncKubeconfigEntries?: KubeconfigSyncEntry[]; } export class UserStore extends BaseStore { @@ -44,8 +52,6 @@ export class UserStore extends BaseStore { configName: "lens-user-store", migrations, }); - - this.handleOnLoad(); } @observable lastSeenAppVersion = "0.0.0"; @@ -71,14 +77,31 @@ export class UserStore extends BaseStore { */ @observable downloadKubectlBinaries = true; @observable openAtLogin = false; + + /** + * The column IDs under each configurable table ID that have been configured + * to not be shown + */ hiddenTableColumns = observable.map>(); - protected async handleOnLoad() { - await this.whenLoaded; + /** + * The set of file/folder paths to be synced + */ + syncKubeconfigEntries = observable.map([ + [path.join(os.homedir(), ".kube"), {}] + ]); + + async load(): Promise { + /** + * This has to be here before the call to `new Config` in `super.load()` + * as we have to make sure that file is in the expected place for that call + */ + await fileNameMigration(); + await super.load(); // refresh new contexts - this.refreshNewContexts(); - reaction(() => this.kubeConfigPath, this.refreshNewContexts); + await this.refreshNewContexts(); + reaction(() => this.kubeConfigPath, () => this.refreshNewContexts()); if (app) { // track telemetry availability @@ -99,16 +122,6 @@ export class UserStore extends BaseStore { } } - async load(): Promise { - /** - * This has to be here before the call to `new Config` in `super.load()` - * as we have to make sure that file is in the expected place for that call - */ - await fileNameMigration(); - - return super.load(); - } - @computed get isNewVersion() { return semver.gt(getAppVersion(), this.lastSeenAppVersion); } @@ -171,7 +184,7 @@ export class UserStore extends BaseStore { this.localeTimezone = tz; } - protected refreshNewContexts = async () => { + protected async refreshNewContexts() { try { const kubeConfig = await readFile(this.kubeConfigPath, "utf8"); @@ -186,7 +199,7 @@ export class UserStore extends BaseStore { logger.error(err); this.resetKubeConfigPath(); } - }; + } @action markNewContextsAsSeen() { @@ -225,37 +238,50 @@ export class UserStore extends BaseStore { this.kubectlBinariesPath = preferences.kubectlBinariesPath; this.openAtLogin = preferences.openAtLogin; - this.hiddenTableColumns.clear(); + if (preferences.hiddenTableColumns) { + this.hiddenTableColumns.replace( + preferences.hiddenTableColumns + .map(([tableId, columnIds]) => [tableId, new ObservableToggleSet(columnIds)]) + ); + } - for (const [tableId, columnIds] of preferences.hiddenTableColumns ?? []) { - this.hiddenTableColumns.set(tableId, new ObservableToggleSet(columnIds)); + if (preferences.syncKubeconfigEntries) { + this.syncKubeconfigEntries.replace( + preferences.syncKubeconfigEntries.map(({ filePath, ...rest }) => [filePath, rest]) + ); } } toJSON(): UserStoreModel { const hiddenTableColumns: [string, string[]][] = []; + const syncKubeconfigEntries: KubeconfigSyncEntry[] = []; for (const [key, values] of this.hiddenTableColumns.entries()) { hiddenTableColumns.push([key, Array.from(values)]); } + for (const [filePath, rest] of this.syncKubeconfigEntries) { + syncKubeconfigEntries.push({ filePath, ...rest }); + } + const model: UserStoreModel = { kubeConfigPath: this.kubeConfigPath, lastSeenAppVersion: this.lastSeenAppVersion, seenContexts: Array.from(this.seenContexts), preferences: { - httpsProxy: this.httpsProxy, - shell: this.shell, - colorTheme: this.colorTheme, - localeTimezone: this.localeTimezone, - allowUntrustedCAs: this.allowUntrustedCAs, - allowTelemetry: this.allowTelemetry, - downloadMirror: this.downloadMirror, - downloadKubectlBinaries: this.downloadKubectlBinaries, - downloadBinariesPath: this.downloadBinariesPath, - kubectlBinariesPath: this.kubectlBinariesPath, - openAtLogin: this.openAtLogin, + httpsProxy: toJS(this.httpsProxy), + shell: toJS(this.shell), + colorTheme: toJS(this.colorTheme), + localeTimezone: toJS(this.localeTimezone), + allowUntrustedCAs: toJS(this.allowUntrustedCAs), + allowTelemetry: toJS(this.allowTelemetry), + downloadMirror: toJS(this.downloadMirror), + downloadKubectlBinaries: toJS(this.downloadKubectlBinaries), + downloadBinariesPath: toJS(this.downloadBinariesPath), + kubectlBinariesPath: toJS(this.kubectlBinariesPath), + openAtLogin: toJS(this.openAtLogin), hiddenTableColumns, + syncKubeconfigEntries, }, }; diff --git a/src/common/utils/extended-map.ts b/src/common/utils/extended-map.ts new file mode 100644 index 0000000000..24fa49b696 --- /dev/null +++ b/src/common/utils/extended-map.ts @@ -0,0 +1,64 @@ +import { action, IEnhancer, IObservableMapInitialValues, ObservableMap } from "mobx"; + +export class ExtendedMap extends Map { + constructor(protected getDefault: () => V, entries?: readonly (readonly [K, V])[] | null) { + super(entries); + } + + getOrInsert(key: K, val: V): V { + if (this.has(key)) { + return this.get(key); + } + + return this.set(key, val).get(key); + } + + getOrInsertWith(key: K, getVal: () => V): V { + if (this.has(key)) { + return this.get(key); + } + + return this.set(key, getVal()).get(key); + } + + getOrDefault(key: K): V { + if (this.has(key)) { + return this.get(key); + } + + return this.set(key, this.getDefault()).get(key); + } +} + +export class ExtendedObservableMap extends ObservableMap { + constructor(protected getDefault: () => V, initialData?: IObservableMapInitialValues, enhancer?: IEnhancer, name?: string) { + super(initialData, enhancer, name); + } + + @action + getOrInsert(key: K, val: V): V { + if (this.has(key)) { + return this.get(key); + } + + return this.set(key, val).get(key); + } + + @action + getOrInsertWith(key: K, getVal: () => V): V { + if (this.has(key)) { + return this.get(key); + } + + return this.set(key, getVal()).get(key); + } + + @action + getOrDefault(key: K): V { + if (this.has(key)) { + return this.get(key); + } + + return this.set(key, this.getDefault()).get(key); + } +} diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index 9fb0477fe4..d4467db958 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -14,6 +14,7 @@ export * from "./disposer"; export * from "./disposer"; export * from "./downloadFile"; export * from "./escapeRegExp"; +export * from "./extended-map"; export * from "./getRandId"; export * from "./openExternal"; export * from "./reject-promise"; diff --git a/src/common/utils/iter.ts b/src/common/utils/iter.ts index f364ce4aee..e65556fcf0 100644 --- a/src/common/utils/iter.ts +++ b/src/common/utils/iter.ts @@ -23,3 +23,67 @@ export function* take(src: Iterable, n: number): Iterable { break outer; } } + +/** + * Creates a new iterator that iterates (lazily) over its input and yields the + * result of `fn` for each item. + * @param src A type that can be iterated over + * @param fn The function that is called for each value + */ +export function* map(src: Iterable, fn: (from: T) => U): Iterable { + for (const from of src) { + yield fn(from); + } +} + +export function* flatMap(src: Iterable, fn: (from: T) => Iterable): Iterable { + for (const from of src) { + yield* fn(from); + } +} + +/** + * Creates a new iterator that iterates (lazily) over its input and yields the + * items that return a `truthy` value from `fn`. + * @param src A type that can be iterated over + * @param fn The function that is called for each value + */ +export function* filter(src: Iterable, fn: (from: T) => any): Iterable { + for (const from of src) { + if (fn(from)) { + yield from; + } + } +} + +/** + * Creates a new iterator that iterates (lazily) over its input and yields the + * result of `fn` when it is `truthy` + * @param src A type that can be iterated over + * @param fn The function that is called for each value + */ +export function* filterMap(src: Iterable, fn: (from: T) => U): Iterable { + for (const from of src) { + const res = fn(from); + + if (res) { + yield res; + } + } +} + +/** + * Creates a new iterator that iterates (lazily) over its input and yields the + * result of `fn` when it is not null or undefined + * @param src A type that can be iterated over + * @param fn The function that is called for each value + */ +export function* filterMapStrict(src: Iterable, fn: (from: T) => U): Iterable { + for (const from of src) { + const res = fn(from); + + if (res != null) { + yield res; + } + } +} diff --git a/src/extensions/lens-main-extension.ts b/src/extensions/lens-main-extension.ts index d01243aac1..d2bab0ba54 100644 --- a/src/extensions/lens-main-extension.ts +++ b/src/extensions/lens-main-extension.ts @@ -20,7 +20,7 @@ export class LensMainExtension extends LensExtension { } addCatalogSource(id: string, source: IObservableArray) { - catalogEntityRegistry.addSource(`${this.name}:${id}`, source); + catalogEntityRegistry.addObservableSource(`${this.name}:${id}`, source); } removeCatalogSource(id: string) { diff --git a/src/main/catalog-pusher.ts b/src/main/catalog-pusher.ts index 73b9b9d580..8bc028cbd7 100644 --- a/src/main/catalog-pusher.ts +++ b/src/main/catalog-pusher.ts @@ -1,7 +1,8 @@ -import { autorun, toJS } from "mobx"; +import { reaction, toJS } from "mobx"; import { broadcastMessage, subscribeToBroadcast, unsubscribeFromBroadcast } from "../common/ipc"; import { CatalogEntityRegistry} from "../common/catalog"; import "../common/catalog-entities/kubernetes-cluster"; +import { Disposer } from "../common/utils"; export class CatalogPusher { static init(catalog: CatalogEntityRegistry) { @@ -11,22 +12,20 @@ export class CatalogPusher { private constructor(private catalog: CatalogEntityRegistry) {} init() { - const disposers: { (): void; }[] = []; + const disposers: Disposer[] = []; - disposers.push(autorun(() => { - this.broadcast(); + disposers.push(reaction(() => this.catalog.items, (items) => { + broadcastMessage("catalog:items", toJS(items, { recurseEverything: true })); + }, { + fireImmediately: true, })); const listener = subscribeToBroadcast("catalog:broadcast", () => { - this.broadcast(); + broadcastMessage("catalog:items", toJS(this.catalog.items, { recurseEverything: true })); }); disposers.push(() => unsubscribeFromBroadcast("catalog:broadcast", listener)); return disposers; } - - broadcast() { - broadcastMessage("catalog:items", toJS(this.catalog.items, { recurseEverything: true })); - } } diff --git a/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts b/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts new file mode 100644 index 0000000000..beda3ca890 --- /dev/null +++ b/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts @@ -0,0 +1,252 @@ +import { ObservableMap } from "mobx"; +import { CatalogEntity } from "../../../common/catalog"; +import { loadFromOptions } from "../../../common/kube-helpers"; +import { Cluster } from "../../cluster"; +import { computeDiff, configToModels } from "../kubeconfig-sync"; +import mockFs from "mock-fs"; +import fs from "fs"; + +describe("kubeconfig-sync.source tests", () => { + beforeEach(() => { + mockFs(); + }); + + afterEach(() => { + mockFs.restore(); + }); + + describe("configsToModels", () => { + it("should filter out invalid split configs", () => { + const config = loadFromOptions({ + clusters: [], + users: [], + contexts: [], + currentContext: "foobar" + }); + + expect(configToModels(config, "").length).toBe(0); + }); + + it("should keep a single valid split config", () => { + const config = loadFromOptions({ + clusters: [{ + name: "cluster-name", + server: "1.2.3.4", + skipTLSVerify: false, + }], + users: [{ + name: "user-name", + }], + contexts: [{ + cluster: "cluster-name", + name: "context-name", + user: "user-name", + }], + currentContext: "foobar" + }); + + const models = configToModels(config, "/bar"); + + expect(models.length).toBe(1); + expect(models[0].contextName).toBe("context-name"); + expect(models[0].kubeConfigPath).toBe("/bar"); + }); + }); + + describe("computeDiff", () => { + it("should leave an empty source empty if there are no entries", () => { + const contents = ""; + const rootSource = new ObservableMap(); + const port = 0; + const filePath = "/bar"; + + computeDiff(contents, rootSource, port, filePath); + + expect(rootSource.size).toBe(0); + }); + + it("should add only the valid clusters to the source", () => { + const contents = JSON.stringify({ + clusters: [{ + name: "cluster-name", + cluster: { + server: "1.2.3.4", + }, + skipTLSVerify: false, + }], + users: [{ + name: "user-name", + }], + contexts: [{ + name: "context-name", + context: { + cluster: "cluster-name", + user: "user-name", + }, + }, { + name: "context-the-second", + context: { + cluster: "missing-cluster", + user: "user-name", + }, + }], + currentContext: "foobar" + }); + const rootSource = new ObservableMap(); + const port = 0; + const filePath = "/bar"; + + fs.writeFileSync(filePath, contents); + + computeDiff(contents, rootSource, port, filePath); + + expect(rootSource.size).toBe(1); + + const c = rootSource.values().next().value[0] as Cluster; + + expect(c.kubeConfigPath).toBe("/bar"); + expect(c.contextName).toBe("context-name"); + }); + + it("should remove a cluster when it is removed from the contents", () => { + const contents = JSON.stringify({ + clusters: [{ + name: "cluster-name", + cluster: { + server: "1.2.3.4", + }, + skipTLSVerify: false, + }], + users: [{ + name: "user-name", + + }], + contexts: [{ + name: "context-name", + context: { + cluster: "cluster-name", + user: "user-name", + }, + }, { + name: "context-the-second", + context: { + cluster: "missing-cluster", + user: "user-name", + }, + }], + currentContext: "foobar" + }); + const rootSource = new ObservableMap(); + const port = 0; + const filePath = "/bar"; + + fs.writeFileSync(filePath, contents); + + computeDiff(contents, rootSource, port, filePath); + + expect(rootSource.size).toBe(1); + + const c = rootSource.values().next().value[0] as Cluster; + + expect(c.kubeConfigPath).toBe("/bar"); + expect(c.contextName).toBe("context-name"); + + computeDiff("{}", rootSource, port, filePath); + + expect(rootSource.size).toBe(0); + }); + + it("should remove only the cluster that it is removed from the contents", () => { + const contents = JSON.stringify({ + clusters: [{ + name: "cluster-name", + cluster: { + server: "1.2.3.4", + }, + skipTLSVerify: false, + }], + users: [{ + name: "user-name", + }, { + name: "user-name-2", + }], + contexts: [{ + name: "context-name", + context: { + cluster: "cluster-name", + user: "user-name", + }, + }, { + name: "context-name-2", + context: { + cluster: "cluster-name", + user: "user-name-2", + }, + }, { + name: "context-the-second", + context: { + cluster: "missing-cluster", + user: "user-name", + }, + }], + currentContext: "foobar" + }); + const rootSource = new ObservableMap(); + const port = 0; + const filePath = "/bar"; + + fs.writeFileSync(filePath, contents); + + computeDiff(contents, rootSource, port, filePath); + + expect(rootSource.size).toBe(2); + + { + const c = rootSource.values().next().value[0] as Cluster; + + expect(c.kubeConfigPath).toBe("/bar"); + expect(["context-name", "context-name-2"].includes(c.contextName)).toBe(true); + } + + const newContents = JSON.stringify({ + clusters: [{ + name: "cluster-name", + cluster: { + server: "1.2.3.4", + }, + skipTLSVerify: false, + }], + users: [{ + name: "user-name", + }, { + name: "user-name-2", + }], + contexts: [{ + name: "context-name", + context: { + cluster: "cluster-name", + user: "user-name", + }, + }, { + name: "context-the-second", + context: { + cluster: "missing-cluster", + user: "user-name", + }, + }], + currentContext: "foobar" + }); + + computeDiff(newContents, rootSource, port, filePath); + + expect(rootSource.size).toBe(1); + + { + const c = rootSource.values().next().value[0] as Cluster; + + expect(c.kubeConfigPath).toBe("/bar"); + expect(c.contextName).toBe("context-name"); + } + }); + }); +}); diff --git a/src/main/catalog-sources/index.ts b/src/main/catalog-sources/index.ts new file mode 100644 index 0000000000..5824760082 --- /dev/null +++ b/src/main/catalog-sources/index.ts @@ -0,0 +1 @@ +export { KubeconfigSyncManager } from "./kubeconfig-sync"; diff --git a/src/main/catalog-sources/kubeconfig-sync.ts b/src/main/catalog-sources/kubeconfig-sync.ts new file mode 100644 index 0000000000..9cf2bf57d4 --- /dev/null +++ b/src/main/catalog-sources/kubeconfig-sync.ts @@ -0,0 +1,251 @@ +import { action, observable, IComputedValue, computed, ObservableMap, runInAction } from "mobx"; +import { CatalogEntity, catalogEntityRegistry } from "../../common/catalog"; +import { watch } from "chokidar"; +import fs from "fs"; +import fse from "fs-extra"; +import * as uuid from "uuid"; +import stream from "stream"; +import { Disposer, ExtendedObservableMap, iter, Singleton } from "../../common/utils"; +import logger from "../logger"; +import { KubeConfig } from "@kubernetes/client-node"; +import { loadConfigFromString, splitConfig, validateKubeConfig } from "../../common/kube-helpers"; +import { Cluster } from "../cluster"; +import { catalogEntityFromCluster } from "../cluster-manager"; +import { UserStore } from "../../common/user-store"; +import { ClusterStore, UpdateClusterModel } from "../../common/cluster-store"; + +const logPrefix = "[KUBECONFIG-SYNC]:"; + +export class KubeconfigSyncManager extends Singleton { + protected sources = observable.map, Disposer]>(); + protected syncing = false; + protected syncListDisposer?: Disposer; + + protected static readonly syncName = "lens:kube-sync"; + + @action + startSync(port: number): void { + if (this.syncing) { + return; + } + + this.syncing = true; + + logger.info(`${logPrefix} starting requested syncs`); + + catalogEntityRegistry.addComputedSource(KubeconfigSyncManager.syncName, computed(() => ( + Array.from(iter.flatMap( + this.sources.values(), + ([entities]) => entities.get() + )) + ))); + + // This must be done so that c&p-ed clusters are visible + this.startNewSync(ClusterStore.storedKubeConfigFolder, port); + + for (const filePath of UserStore.getInstance().syncKubeconfigEntries.keys()) { + this.startNewSync(filePath, port); + } + + this.syncListDisposer = UserStore.getInstance().syncKubeconfigEntries.observe(change => { + switch (change.type) { + case "add": + this.startNewSync(change.name, port); + break; + case "delete": + this.stopOldSync(change.name); + break; + } + }); + } + + @action + stopSync() { + this.syncListDisposer?.(); + + for (const filePath of this.sources.keys()) { + this.stopOldSync(filePath); + } + + catalogEntityRegistry.removeSource(KubeconfigSyncManager.syncName); + this.syncing = false; + } + + @action + protected async startNewSync(filePath: string, port: number): Promise { + if (this.sources.has(filePath)) { + // don't start a new sync if we already have one + return void logger.debug(`${logPrefix} already syncing file/folder`, { filePath }); + } + + try { + this.sources.set(filePath, await watchFileChanges(filePath, port)); + + logger.info(`${logPrefix} starting sync of file/folder`, { filePath }); + logger.debug(`${logPrefix} ${this.sources.size} files/folders watched`, { files: Array.from(this.sources.keys()) }); + } catch (error) { + logger.warn(`${logPrefix} failed to start watching changes: ${error}`); + } + } + + @action + protected stopOldSync(filePath: string): void { + if (!this.sources.delete(filePath)) { + // already stopped + return void logger.debug(`${logPrefix} no syncing file/folder to stop`, { filePath }); + } + + logger.info(`${logPrefix} stopping sync of file/folder`, { filePath }); + logger.debug(`${logPrefix} ${this.sources.size} files/folders watched`, { files: Array.from(this.sources.keys()) }); + } +} + +// exported for testing +export function configToModels(config: KubeConfig, filePath: string): UpdateClusterModel[] { + const validConfigs = []; + + for (const contextConfig of splitConfig(config)) { + const error = validateKubeConfig(contextConfig, contextConfig.currentContext); + + if (error) { + logger.debug(`${logPrefix} context failed validation: ${error}`, { context: contextConfig.currentContext, filePath }); + } else { + validConfigs.push({ + kubeConfigPath: filePath, + contextName: contextConfig.currentContext, + }); + } + } + + return validConfigs; +} + +type RootSourceValue = [Cluster, CatalogEntity]; +type RootSource = ObservableMap; + +// exported for testing +export function computeDiff(contents: string, source: RootSource, port: number, filePath: string): void { + runInAction(() => { + try { + const rawModels = configToModels(loadConfigFromString(contents), filePath); + const models = new Map(rawModels.map(m => [m.contextName, m])); + + logger.debug(`${logPrefix} File now has ${models.size} entries`, { filePath }); + + for (const [contextName, value] of source) { + const model = models.get(contextName); + + // remove and disconnect clusters that were removed from the config + if (!model) { + value[0].disconnect(); + source.delete(contextName); + logger.debug(`${logPrefix} Removed old cluster from sync`, { filePath, contextName }); + continue; + } + + // TODO: For the update check we need to make sure that the config itself hasn't changed. + // Probably should make it so that cluster keeps a copy of the config in its memory and + // diff against that + + // or update the model and mark it as not needed to be added + value[0].updateModel(model); + models.delete(contextName); + logger.debug(`${logPrefix} Updated old cluster from sync`, { filePath, contextName }); + } + + for (const [contextName, model] of models) { + // add new clusters to the source + try { + const cluster = new Cluster({ ...model, id: uuid.v4() }); + + if (!cluster.apiUrl) { + throw new Error("Cluster constructor failed, see above error"); + } + + cluster.init(port); + + const entity = catalogEntityFromCluster(cluster); + + entity.metadata.labels.file = filePath; + source.set(contextName, [cluster, entity]); + + logger.debug(`${logPrefix} Added new cluster from sync`, { filePath, contextName }); + } catch (error) { + logger.warn(`${logPrefix} Failed to create cluster from model: ${error}`, { filePath, contextName }); + } + } + } catch (error) { + logger.warn(`${logPrefix} Failed to compute diff: ${error}`, { filePath }); + source.clear(); // clear source if we have failed so as to not show outdated information + } + }); +} + +function diffChangedConfig(filePath: string, source: RootSource, port: number): Disposer { + logger.debug(`${logPrefix} file changed`, { filePath }); + + // TODO: replace with an AbortController with fs.readFile when we upgrade to Node 16 (after it comes out) + const fileReader = fs.createReadStream(filePath, { + mode: fs.constants.O_RDONLY, + }); + const readStream: stream.Readable = fileReader; + const bufs: Buffer[] = []; + let closed = false; + + const cleanup = () => { + closed = true; + fileReader.close(); // This may not close the stream. + // Artificially marking end-of-stream, as if the underlying resource had + // indicated end-of-file by itself, allows the stream to close. + // This does not cancel pending read operations, and if there is such an + // operation, the process may still not be able to exit successfully + // until it finishes. + fileReader.push(null); + fileReader.read(0); + readStream.removeAllListeners(); + }; + + readStream + .on("data", chunk => bufs.push(chunk)) + .on("close", () => cleanup()) + .on("error", error => { + cleanup(); + logger.warn(`${logPrefix} failed to read file: ${error}`, { filePath }); + }) + .on("end", () => { + if (!closed) { + computeDiff(Buffer.concat(bufs).toString("utf-8"), source, port, filePath); + } + }); + + return cleanup; +} + +async function watchFileChanges(filePath: string, port: number): Promise<[IComputedValue, Disposer]> { + const stat = await fse.stat(filePath); // traverses symlinks, is a race condition + const watcher = watch(filePath, { + followSymlinks: true, + depth: stat.isDirectory() ? 0 : 1, // DIRs works with 0 but files need 1 (bug: https://github.com/paulmillr/chokidar/issues/1095) + disableGlobbing: true, + }); + const rootSource = new ExtendedObservableMap>(observable.map); + const derivedSource = computed(() => Array.from(iter.flatMap(rootSource.values(), from => iter.map(from.values(), child => child[1])))); + const stoppers = new Map(); + + watcher + .on("change", (childFilePath) => { + stoppers.get(childFilePath)(); + stoppers.set(childFilePath, diffChangedConfig(childFilePath, rootSource.getOrDefault(childFilePath), port)); + }) + .on("add", (childFilePath) => { + stoppers.set(childFilePath, diffChangedConfig(childFilePath, rootSource.getOrDefault(childFilePath), port)); + }) + .on("unlink", (childFilePath) => { + stoppers.get(childFilePath)(); + stoppers.delete(childFilePath); + rootSource.delete(childFilePath); + }) + .on("error", error => logger.error(`${logPrefix} watching file/folder failed: ${error}`, { filePath })); + + return [derivedSource, () => watcher.close()]; +} diff --git a/src/main/cluster-manager.ts b/src/main/cluster-manager.ts index c1485881e4..369c4f61b9 100644 --- a/src/main/cluster-manager.ts +++ b/src/main/cluster-manager.ts @@ -18,7 +18,7 @@ export class ClusterManager extends Singleton { constructor(public readonly port: number) { super(); - catalogEntityRegistry.addSource("lens:kubernetes-clusters", this.catalogSource); + catalogEntityRegistry.addObservableSource("lens:kubernetes-clusters", this.catalogSource); // auto-init clusters reaction(() => ClusterStore.getInstance().enabledClustersList, (clusters) => { clusters.forEach((cluster) => { @@ -59,17 +59,17 @@ export class ClusterManager extends Singleton { } @action protected updateCatalogSource(clusters: Cluster[]) { - this.catalogSource.forEach((entity, index) => { - const clusterIndex = clusters.findIndex((cluster) => entity.metadata.uid === cluster.id); + this.catalogSource.replace(this.catalogSource.filter(entity => ( + clusters.find((cluster) => entity.metadata.uid === cluster.id) + ))); - if (clusterIndex === -1) { - this.catalogSource.splice(index, 1); + for (const cluster of clusters) { + if (cluster.ownerRef) { + continue; } - }); - clusters.filter((c) => !c.ownerRef).forEach((cluster) => { const entityIndex = this.catalogSource.findIndex((entity) => entity.metadata.uid === cluster.id); - const newEntity = this.catalogEntityFromCluster(cluster); + const newEntity = catalogEntityFromCluster(cluster); if (entityIndex === -1) { this.catalogSource.push(newEntity); @@ -84,11 +84,15 @@ export class ClusterManager extends Singleton { }; this.catalogSource.splice(entityIndex, 1, newEntity); } - }); + } } @action syncClustersFromCatalog(entities: KubernetesCluster[]) { - entities.filter((entity) => entity.metadata.source !== "local").forEach((entity: KubernetesCluster) => { + for (const entity of entities) { + if (entity.metadata.source !== "local") { + continue; + } + const cluster = ClusterStore.getInstance().getById(entity.metadata.uid); if (!cluster) { @@ -104,7 +108,7 @@ export class ClusterManager extends Singleton { }); } else { cluster.enabled = true; - if (!cluster.ownerRef) cluster.ownerRef = clusterOwnerRef; + cluster.ownerRef ||= clusterOwnerRef; cluster.preferences.clusterName = entity.metadata.name; cluster.kubeConfigPath = entity.spec.kubeconfigPath; cluster.contextName = entity.spec.kubeconfigContext; @@ -114,32 +118,7 @@ export class ClusterManager extends Singleton { active: !cluster.disconnected }; } - }); - } - - protected catalogEntityFromCluster(cluster: Cluster) { - return new KubernetesCluster(toJS({ - apiVersion: "entity.k8slens.dev/v1alpha1", - kind: "KubernetesCluster", - metadata: { - uid: cluster.id, - name: cluster.name, - source: "local", - labels: { - "distro": (cluster.metadata["distribution"] || "unknown").toString() - } - }, - spec: { - kubeconfigPath: cluster.kubeConfigPath, - kubeconfigContext: cluster.contextName - }, - status: { - phase: cluster.disconnected ? "disconnected" : "connected", - reason: "", - message: "", - active: !cluster.disconnected - } - })); + } } protected onNetworkOffline() { @@ -192,3 +171,28 @@ export class ClusterManager extends Singleton { return cluster; } } + +export function catalogEntityFromCluster(cluster: Cluster) { + return new KubernetesCluster(toJS({ + apiVersion: "entity.k8slens.dev/v1alpha1", + kind: "KubernetesCluster", + metadata: { + uid: cluster.id, + name: cluster.name, + source: "local", + labels: { + distro: cluster.distribution, + } + }, + spec: { + kubeconfigPath: cluster.kubeConfigPath, + kubeconfigContext: cluster.contextName + }, + status: { + phase: cluster.disconnected ? "disconnected" : "connected", + reason: "", + message: "", + active: !cluster.disconnected + } + })); +} diff --git a/src/main/cluster.ts b/src/main/cluster.ts index 6606a71605..d55a243f99 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -1,5 +1,5 @@ import { ipcMain } from "electron"; -import type { ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences } from "../common/cluster-store"; +import type { ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences, UpdateClusterModel } from "../common/cluster-store"; import type { IMetricsReqParams } from "../renderer/api/endpoints/metrics.api"; import { action, comparer, computed, observable, reaction, toJS, when } from "mobx"; import { apiKubePrefix } from "../common/vars"; @@ -57,7 +57,7 @@ export interface ClusterState { */ export class Cluster implements ClusterModel, ClusterState { /** Unique id for a cluster */ - public id: ClusterId; + public readonly id: ClusterId; /** * Kubectl * @@ -85,7 +85,7 @@ export class Cluster implements ClusterModel, ClusterState { whenReady = when(() => this.ready); /** - * Is cluster object initializinng on-going + * Is cluster object initializing on-going * * @observable */ @@ -231,6 +231,10 @@ export class Cluster implements ClusterModel, ClusterState { return this.preferences.clusterName || this.contextName; } + @computed get distribution(): string { + return this.metadata.distribution?.toString() || "unknown"; + } + /** * Prometheus preferences * @@ -253,12 +257,17 @@ export class Cluster implements ClusterModel, ClusterState { } constructor(model: ClusterModel) { + this.id = model.id; this.updateModel(model); try { const kubeconfig = this.getKubeconfig(); + const error = validateKubeConfig(kubeconfig, this.contextName, { validateCluster: true, validateUser: false, validateExec: false}); + + if (error) { + throw error; + } - validateKubeConfig(kubeconfig, this.contextName, { validateCluster: true, validateUser: false, validateExec: false}); this.apiUrl = kubeconfig.getCluster(kubeconfig.getContextObject(this.contextName).cluster).server; } catch(err) { logger.error(err); @@ -279,8 +288,34 @@ export class Cluster implements ClusterModel, ClusterState { * * @param model */ - @action updateModel(model: ClusterModel) { - Object.assign(this, model); + @action updateModel(model: UpdateClusterModel) { + // Note: do not assign ID as that should never be updated + + this.kubeConfigPath = model.kubeConfigPath; + + if (model.workspace) { + this.workspace = model.workspace; + } + + if (model.contextName) { + this.contextName = model.contextName; + } + + if (model.preferences) { + this.preferences = model.preferences; + } + + if (model.metadata) { + this.metadata = model.metadata; + } + + if (model.ownerRef) { + this.ownerRef = model.ownerRef; + } + + if (model.accessibleNamespaces) { + this.accessibleNamespaces = model.accessibleNamespaces; + } } /** diff --git a/src/main/index.ts b/src/main/index.ts index 42f965fe89..2b4a7ae911 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -33,6 +33,7 @@ import { CatalogPusher } from "./catalog-pusher"; import { catalogEntityRegistry } from "../common/catalog"; import { HotbarStore } from "../common/hotbar-store"; import { HelmRepoManager } from "./helm/helm-repo-manager"; +import { KubeconfigSyncManager } from "./catalog-sources"; const workingDir = path.join(app.getPath("appData"), appName); @@ -131,6 +132,9 @@ app.on("ready", async () => { const clusterManager = ClusterManager.getInstance(); + // create kubeconfig sync manager + KubeconfigSyncManager.createInstance().startSync(clusterManager.port); + // run proxy try { logger.info("🔌 Starting LensProxy"); @@ -237,6 +241,7 @@ app.on("will-quit", (event) => { logger.info("APP:QUIT"); appEventBus.emit({name: "app", action: "close"}); ClusterManager.getInstance(false)?.stop(); // close cluster connections + KubeconfigSyncManager.getInstance(false)?.stopSync(); if (blockQuit) { event.preventDefault(); // prevent app's default shutdown (e.g. required for telemetry, etc.) diff --git a/src/renderer/api/__tests__/catalog-entity-registry.test.ts b/src/renderer/api/__tests__/catalog-entity-registry.test.ts index e7c14f587c..23720ec967 100644 --- a/src/renderer/api/__tests__/catalog-entity-registry.test.ts +++ b/src/renderer/api/__tests__/catalog-entity-registry.test.ts @@ -43,27 +43,6 @@ describe("CatalogEntityRegistry", () => { expect(catalog.items.length).toEqual(2); }); - it("ignores unknown items", () => { - const catalog = new CatalogEntityRegistry(catalogCategoryRegistry); - const items = [{ - apiVersion: "entity.k8slens.dev/v1alpha1", - kind: "FooBar", - metadata: { - uid: "123", - name: "foobar", - source: "test", - labels: {} - }, - status: { - phase: "disconnected" - }, - spec: {} - }]; - - catalog.updateItems(items); - expect(catalog.items.length).toEqual(0); - }); - it("updates existing items", () => { const catalog = new CatalogEntityRegistry(catalogCategoryRegistry); const items = [{ diff --git a/src/renderer/api/catalog-entity-registry.ts b/src/renderer/api/catalog-entity-registry.ts index 4b2d72ea85..c542c86d43 100644 --- a/src/renderer/api/catalog-entity-registry.ts +++ b/src/renderer/api/catalog-entity-registry.ts @@ -17,27 +17,7 @@ export class CatalogEntityRegistry { } @action updateItems(items: (CatalogEntityData & CatalogEntityKindData)[]) { - this._items.forEach((item, index) => { - const foundIndex = items.findIndex((i) => i.apiVersion === item.apiVersion && i.kind === item.kind && i.metadata.uid === item.metadata.uid); - - if (foundIndex === -1) { - this._items.splice(index, 1); - } - }); - - items.forEach((data) => { - const item = this.categoryRegistry.getEntityForData(data); - - if (!item) return; // invalid data - - const index = this._items.findIndex((i) => i.apiVersion === item.apiVersion && i.kind === item.kind && i.metadata.uid === item.metadata.uid); - - if (index === -1) { - this._items.push(item); - } else { - this._items.splice(index, 1, item); - } - }); + this._items = items.map(data => this.categoryRegistry.getEntityForData(data)); } set activeEntity(entity: CatalogEntity) { diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index 8be97cc86d..43b1c170eb 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -21,6 +21,7 @@ import { LensApp } from "./lens-app"; import { ThemeStore } from "./theme.store"; import { HelmRepoManager } from "../main/helm/helm-repo-manager"; import { ExtensionInstallationStateStore } from "./components/+extensions/extension-install.store"; +import { DefaultProps } from "./mui-base-theme"; /** * If this is a development buid, wait a second to attach @@ -92,7 +93,7 @@ export async function bootstrap(App: AppComponent) { }); render(<> {isMac &&
} - + {DefaultProps(App)} , rootElem); } diff --git a/src/renderer/components/+add-cluster/add-cluster.tsx b/src/renderer/components/+add-cluster/add-cluster.tsx index ff09486ac3..4c65104c7e 100644 --- a/src/renderer/components/+add-cluster/add-cluster.tsx +++ b/src/renderer/components/+add-cluster/add-cluster.tsx @@ -11,7 +11,7 @@ import { AceEditor } from "../ace-editor"; import { Button } from "../button"; import { Icon } from "../icon"; import { kubeConfigDefaultPath, loadConfig, splitConfig, validateConfig, validateKubeConfig } from "../../../common/kube-helpers"; -import { ClusterModel, ClusterStore } from "../../../common/cluster-store"; +import { ClusterStore } from "../../../common/cluster-store"; import { v4 as uuid } from "uuid"; import { navigate } from "../../navigation"; import { UserStore } from "../../../common/user-store"; @@ -132,36 +132,28 @@ export class AddCluster extends React.Component { }; @action - addClusters = () => { - let newClusters: ClusterModel[] = []; - + addClusters = (): void => { try { if (!this.selectedContexts.length) { - this.error = "Please select at least one cluster context"; - - return; + return void (this.error = "Please select at least one cluster context"); } + this.error = ""; this.isWaiting = true; appEventBus.emit({ name: "cluster-add", action: "click" }); - newClusters = this.selectedContexts.filter(context => { - try { - const kubeConfig = this.kubeContexts.get(context); + const newClusters = this.selectedContexts.filter(context => { + const kubeConfig = this.kubeContexts.get(context); + const error = validateKubeConfig(kubeConfig, context); - validateKubeConfig(kubeConfig, context); + if (error) { + this.error = error.toString(); - return true; - } catch (err) { - this.error = String(err.message); - - if (err instanceof ExecValidationNotFoundError) { + if (error instanceof ExecValidationNotFoundError) { Notifications.error(<>Error while adding cluster(s): {this.error}); - - return false; - } else { - throw new Error(err); } } + + return Boolean(!error); }).map(context => { const clusterId = uuid(); const kubeConfig = this.kubeContexts.get(context); diff --git a/src/renderer/components/+catalog/catalog.scss b/src/renderer/components/+catalog/catalog.scss index e88e786800..6e711cdf2b 100644 --- a/src/renderer/components/+catalog/catalog.scss +++ b/src/renderer/components/+catalog/catalog.scss @@ -57,4 +57,23 @@ color: var(--halfGray); } } + + .TableCell.labels { + overflow-x: scroll; + text-overflow: unset; + + &::-webkit-scrollbar { + display: none; + } + + .Badge { + overflow: unset; + text-overflow: unset; + max-width: unset; + + &:not(:first-child) { + margin-left: 0.5em; + } + } + } } diff --git a/src/renderer/components/+preferences/kubeconfig-syncs.tsx b/src/renderer/components/+preferences/kubeconfig-syncs.tsx new file mode 100644 index 0000000000..89432dcac8 --- /dev/null +++ b/src/renderer/components/+preferences/kubeconfig-syncs.tsx @@ -0,0 +1,176 @@ +import React from "react"; +import { remote } from "electron"; +import { Avatar, IconButton, List, ListItem, ListItemAvatar, ListItemSecondaryAction, ListItemText, Paper } from "@material-ui/core"; +import { Description, Folder, Delete, HelpOutline } from "@material-ui/icons"; +import { action, computed, observable, reaction } from "mobx"; +import { disposeOnUnmount, observer } from "mobx-react"; +import fse from "fs-extra"; +import { KubeconfigSyncEntry, KubeconfigSyncValue, UserStore } from "../../../common/user-store"; +import { Button } from "../button"; +import { SubTitle } from "../layout/sub-title"; +import { Spinner } from "../spinner"; +import logger from "../../../main/logger"; +import { iter } from "../../utils"; + +interface SyncInfo { + type: "file" | "folder" | "unknown"; +} + +interface Entry extends Value { + filePath: string; +} + +interface Value { + data: KubeconfigSyncValue; + info: SyncInfo; +} + +async function getMapEntry({ filePath, ...data}: KubeconfigSyncEntry): Promise<[string, Value]> { + try { + // stat follows the stat(2) linux syscall spec, namely it follows symlinks + const stats = await fse.stat(filePath); + + if (stats.isFile()) { + return [filePath, { info: { type: "file" }, data }]; + } + + if (stats.isDirectory()) { + return [filePath, { info: { type: "folder" }, data }]; + } + + logger.warn("[KubeconfigSyncs]: unknown stat entry", { stats }); + + return [filePath, { info: { type: "unknown" }, data }]; + } catch (error) { + logger.warn(`[KubeconfigSyncs]: failed to stat entry: ${error}`, { error }); + + return [filePath, { info: { type: "unknown" }, data }]; + } +} + +@observer +export class KubeconfigSyncs extends React.Component { + syncs = observable.map(); + @observable loaded = false; + + async componentDidMount() { + const mapEntries = await Promise.all( + iter.map( + UserStore.getInstance().syncKubeconfigEntries, + ([filePath, ...value]) => getMapEntry({ filePath, ...value }), + ), + ); + + this.syncs.replace(mapEntries); + this.loaded = true; + + disposeOnUnmount(this, [ + reaction(() => Array.from(this.syncs.entries(), ([filePath, { data }]) => [filePath, data]), syncs => { + UserStore.getInstance().syncKubeconfigEntries.replace(syncs); + }) + ]); + } + + @computed get syncsList(): Entry[] | undefined { + if (!this.loaded) { + return undefined; + } + + return Array.from(this.syncs.entries(), ([filePath, value]) => ({ filePath, ...value })); + } + + @action + openFileDialog = async () => { + const { dialog, BrowserWindow } = remote; + const { canceled, filePaths } = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), { + properties: ["openFile", "showHiddenFiles", "multiSelections", "openDirectory"], + message: "Select kubeconfig file(s) and folder(s)", + buttonLabel: "Sync", + }); + + if (canceled) { + return; + } + + const newEntries = await Promise.all(filePaths.map(filePath => getMapEntry({ filePath }))); + + for (const [filePath, info] of newEntries) { + this.syncs.set(filePath, info); + } + }; + + renderEntryIcon(entry: Entry) { + switch (entry.info.type) { + case "file": + return ; + case "folder": + return ; + case "unknown": + return ; + } + } + + renderEntry = (entry: Entry) => { + return ( + + + + + {this.renderEntryIcon(entry)} + + + + + this.syncs.delete(entry.filePath)} + > + + + + + + ); + }; + + renderEntries() { + const entries = this.syncsList; + + if (!entries) { + return ( +
+ +
+ ); + } + + return ( + + {entries.map(this.renderEntry)} + + ); + } + + render() { + return ( + <> +
+ +
+ + ); + } +} diff --git a/src/renderer/components/+preferences/preferences.scss b/src/renderer/components/+preferences/preferences.scss index 5c02312432..4320def3d0 100644 --- a/src/renderer/components/+preferences/preferences.scss +++ b/src/renderer/components/+preferences/preferences.scss @@ -1,2 +1,32 @@ .Preferences { -} \ No newline at end of file + .loading-spinner { + margin: auto; + } + + .kubeconfig-sync-list { + .entry { + &.MuiPaper-root { + background-color: var(--inputControlBackground); + margin-bottom: var(--flex-gap, 1em); + color: inherit; + } + + .MuiAvatar-root { + color: var(--buttonPrimaryBackground); + font-size: calc(2.5 * var(--unit)); + } + + .description { + font-family: monospace; + + .MuiTypography-body1 { + font-size: var(--font-size); + } + } + + .action .MuiIconButton-root { + font-size: calc(2.5 * var(--unit)); + } + } + } +} diff --git a/src/renderer/components/+preferences/preferences.tsx b/src/renderer/components/+preferences/preferences.tsx index d101d614ba..ea529320b8 100644 --- a/src/renderer/components/+preferences/preferences.tsx +++ b/src/renderer/components/+preferences/preferences.tsx @@ -18,6 +18,7 @@ import { KubectlBinaries } from "./kubectl-binaries"; import { navigation } from "../../navigation"; import { Tab, Tabs } from "../tabs"; import { FormSwitch, Switcher } from "../switch"; +import { KubeconfigSyncs } from "./kubeconfig-syncs"; enum Pages { Application = "application", @@ -209,7 +210,6 @@ export class Preferences extends React.Component { )} - {this.activeTab == Pages.Kubernetes && (
@@ -217,20 +217,23 @@ export class Preferences extends React.Component {

+
+

Kubeconfig Syncs

+ +
+

Helm Charts

)} - {this.activeTab == Pages.Telemetry && (

Telemetry

{telemetryExtensions.map(this.renderExtension)}
)} - {this.activeTab == Pages.Extensions && (

Extensions

diff --git a/src/renderer/mui-base-theme.tsx b/src/renderer/mui-base-theme.tsx new file mode 100644 index 0000000000..284e2ca40a --- /dev/null +++ b/src/renderer/mui-base-theme.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { createMuiTheme, ThemeProvider } from "@material-ui/core"; + +const defaultTheme = createMuiTheme({ + props: { + MuiIconButton: { + color: "inherit", + }, + MuiSvgIcon: { + fontSize: "inherit", + }, + MuiTooltip: { + placement: "top", + } + }, + overrides: { + MuiIconButton: { + root: { + "&:hover": { + color: "var(--iconActiveColor)", + backgroundColor: "var(--iconActiveBackground)", + } + } + } + }, +}); + +export function DefaultProps(App: React.ComponentType) { + return ( + + + + ); +} diff --git a/yarn.lock b/yarn.lock index db1969ce97..e5443a82e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -838,6 +838,13 @@ react-is "^16.8.0" react-transition-group "^4.4.0" +"@material-ui/icons@^4.11.2": + version "4.11.2" + resolved "https://registry.yarnpkg.com/@material-ui/icons/-/icons-4.11.2.tgz#b3a7353266519cd743b6461ae9fdfcb1b25eb4c5" + integrity sha512-fQNsKX2TxBmqIGJCSi3tGTO/gZ+eJgWmMJkgDiOfyNaunNaxcklJQFaFogYcFl0qFuaEz1qaXYXboa/bUXVSOQ== + dependencies: + "@babel/runtime" "^7.4.4" + "@material-ui/lab@^4.0.0-alpha.57": version "4.0.0-alpha.57" resolved "https://registry.yarnpkg.com/@material-ui/lab/-/lab-4.0.0-alpha.57.tgz#e8961bcf6449e8a8dabe84f2700daacfcafbf83a" @@ -1364,13 +1371,13 @@ jest-diff "^25.2.1" pretty-format "^25.2.1" -"@types/jest@^25.2.3": - version "25.2.3" - resolved "https://registry.yarnpkg.com/@types/jest/-/jest-25.2.3.tgz#33d27e4c4716caae4eced355097a47ad363fdcaf" - integrity sha512-JXc1nK/tXHiDhV55dvfzqtmP4S3sy3T3ouV2tkViZgxY/zeUkcpQcQPGRlgF4KmWzWW5oiWYSZwtCB+2RsE4Fw== +"@types/jest@^26.0.22": + version "26.0.22" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-26.0.22.tgz#8308a1debdf1b807aa47be2838acdcd91e88fbe6" + integrity sha512-eeWwWjlqxvBxc4oQdkueW5OF/gtfSceKk4OnOAGlUSwS/liBRtZppbJuz1YkgbrbfGOoeBHun9fOvXnjNwrSOw== dependencies: - jest-diff "^25.2.1" - pretty-format "^25.2.1" + jest-diff "^26.0.0" + pretty-format "^26.0.0" "@types/js-yaml@^3.12.1", "@types/js-yaml@^3.12.4": version "3.12.4" @@ -13660,7 +13667,7 @@ ts-essentials@^4.0.0: resolved "https://registry.yarnpkg.com/ts-essentials/-/ts-essentials-4.0.0.tgz#506c42b270bbd0465574b90416533175b09205ab" integrity sha512-uQJX+SRY9mtbKU+g9kl5Fi7AEMofPCvHfJkQlaygpPmHPZrtgaBqbWFOYyiA47RhnSwwnXdepUJrgqUYxoUyhQ== -ts-jest@^26.1.0: +ts-jest@26.3.0: version "26.3.0" resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-26.3.0.tgz#6b2845045347dce394f069bb59358253bc1338a9" integrity sha512-Jq2uKfx6bPd9+JDpZNMBJMdMQUC3sJ08acISj8NXlVgR2d5OqslEHOR2KHMgwymu8h50+lKIm0m0xj/ioYdW2Q==