From 159ff5c98e08fd5177d693c13d6e06e8c87b2f32 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Wed, 21 Apr 2021 16:19:14 -0400 Subject: [PATCH] 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 --- integration/helpers/minikube.ts | 3 + package.json | 3 +- .../__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 | 50 +++- src/common/user-store.ts | 83 ++++-- 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 +- .../__test__/kubeconfig-sync.source.test.ts | 252 ++++++++++++++++++ src/main/catalog-sources/index.ts | 1 + .../catalog-sources/kubeconfig-sync.source.ts | 234 ++++++++++++++++ src/main/cluster-manager.ts | 78 +++--- src/main/cluster.ts | 47 +++- src/main/index.ts | 5 + src/migrations/user-store/5.0.0-alpha.2.ts | 18 ++ src/renderer/bootstrap.tsx | 35 ++- .../components/+add-cluster/add-cluster.tsx | 32 +-- src/renderer/components/+catalog/catalog.scss | 19 ++ .../+preferences/kubeconfig-syncs.tsx | 174 ++++++++++++ .../components/+preferences/preferences.scss | 28 +- .../components/+preferences/preferences.tsx | 9 +- src/renderer/components/app.scss | 2 +- yarn.lock | 19 +- 30 files changed, 1161 insertions(+), 152 deletions(-) create mode 100644 src/common/utils/extended-map.ts create mode 100644 src/main/catalog-sources/__test__/kubeconfig-sync.source.test.ts create mode 100644 src/main/catalog-sources/index.ts create mode 100644 src/main/catalog-sources/kubeconfig-sync.source.ts create mode 100644 src/migrations/user-store/5.0.0-alpha.2.ts create mode 100644 src/renderer/components/+preferences/kubeconfig-syncs.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 31c39192a4..b5fdd16e23 100644 --- a/package.json +++ b/package.json @@ -172,6 +172,7 @@ "@hapi/call": "^8.0.0", "@hapi/subtext": "^7.0.3", "@kubernetes/client-node": "^0.12.0", + "@material-ui/icons": "^4.11.2", "abort-controller": "^3.0.0", "array-move": "^3.0.0", "await-lock": "^2.1.0", @@ -250,7 +251,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", 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 ddecc6bde8..46e93cf57d 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("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("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("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("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 fb4691fedb..0b001ebf18 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 checkRawCluster(rawCluster: any): boolean { + return rawCluster?.name && rawCluster?.cluster?.server; +} + +function checkRawUser(rawUser: any): boolean { + return rawUser?.name; +} + function checkRawContext(rawContext: any): boolean { return rawContext.name && rawContext.context?.cluster && rawContext.context?.user; } -function loadToOptions(rawYaml: string): any { +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 @@ -182,7 +207,7 @@ export function getNodeWarningConditions(node: V1Node) { /** * Checks if `config` has valid `Context`, `User`, `Cluster`, and `exec` fields (if present when required) */ -export function validateKubeConfig (config: KubeConfig, contextName: string, validationOpts: KubeConfigValidationOpts = {}) { +export function validateKubeConfig(config: KubeConfig, contextName: string, validationOpts: KubeConfigValidationOpts = {}): Error | undefined { // 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 @@ -192,19 +217,19 @@ export function validateKubeConfig (config: KubeConfig, contextName: string, val // Validate the Context Object if (!contextObject) { - throw new Error(`No valid context object provided in kubeconfig for context '${contextName}'`); + return 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}'`); + 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) { - throw new Error(`No valid user object provided in kubeconfig for context '${contextName}'`); + return new Error(`No valid user object provided in kubeconfig for context '${contextName}'`); } // Validate exec command if present @@ -216,7 +241,8 @@ export function validateKubeConfig (config: KubeConfig, contextName: string, val // 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); + + return new ExecValidationNotFoundError(execCommand, isAbsolute); } } } diff --git a/src/common/user-store.ts b/src/common/user-store.ts index 1118344d02..6a86640516 100644 --- a/src/common/user-store.ts +++ b/src/common/user-store.ts @@ -11,8 +11,10 @@ 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"; +import { ClusterStore } from "./cluster-store"; export interface UserStoreModel { kubeConfigPath: string; @@ -21,6 +23,10 @@ export interface UserStoreModel { preferences: UserPreferencesModel; } +export interface KubeconfigSyncEntry { + filePath: string; +} + export interface UserPreferencesModel { httpsProxy?: string; shell?: string; @@ -34,6 +40,7 @@ export interface UserPreferencesModel { kubectlBinariesPath?: string; openAtLogin?: boolean; hiddenTableColumns?: [string, string[]][]; + syncKubeconfigEntries?: KubeconfigSyncEntry[]; } export class UserStore extends BaseStore { @@ -44,8 +51,6 @@ export class UserStore extends BaseStore { configName: "lens-user-store", migrations, }); - - this.handleOnLoad(); } @observable lastSeenAppVersion = "0.0.0"; @@ -71,14 +76,32 @@ 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.set([ + path.join(os.homedir(), ".kube"), + ClusterStore.storedKubeConfigFolder, + ]); + + 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() { @@ -230,32 +243,44 @@ export class UserStore extends BaseStore { for (const [tableId, columnIds] of preferences.hiddenTableColumns ?? []) { this.hiddenTableColumns.set(tableId, new ObservableToggleSet(columnIds)); } + + if (preferences.syncKubeconfigEntries) { + this.syncKubeconfigEntries.replace(preferences.syncKubeconfigEntries.map(({ filePath }) => filePath)); + } else { + this.syncKubeconfigEntries.clear(); + } } 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 of this.syncKubeconfigEntries) { + syncKubeconfigEntries.push({ filePath }); + } + 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 edb2b44d96..ac3defa50b 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -13,6 +13,7 @@ export * from "./delay"; export * from "./disposer"; export * from "./downloadFile"; export * from "./escapeRegExp"; +export * from "./extended-map"; export * from "./getRandId"; export * from "./openExternal"; export * from "./saveToAppFiles"; 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-sources/__test__/kubeconfig-sync.source.test.ts b/src/main/catalog-sources/__test__/kubeconfig-sync.source.test.ts new file mode 100644 index 0000000000..0d0159a579 --- /dev/null +++ b/src/main/catalog-sources/__test__/kubeconfig-sync.source.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.source"; +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..c519f37161 --- /dev/null +++ b/src/main/catalog-sources/index.ts @@ -0,0 +1 @@ +export { KubeconfigSyncManager } from "./kubeconfig-sync.source"; diff --git a/src/main/catalog-sources/kubeconfig-sync.source.ts b/src/main/catalog-sources/kubeconfig-sync.source.ts new file mode 100644 index 0000000000..35ef82e6bd --- /dev/null +++ b/src/main/catalog-sources/kubeconfig-sync.source.ts @@ -0,0 +1,234 @@ +import { action, observable, IComputedValue, computed, ObservableMap, runInAction } from "mobx"; +import { CatalogEntity, catalogEntityRegistry } from "../../common/catalog"; +import { watch } from "chokidar"; +import fs from "fs"; +import * as uuid from "uuid"; +import stream from "stream"; +import { 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 { UpdateClusterModel } from "../../common/cluster-store"; + +const logPrefix = "[KUBECONFIG-SYNC]:"; + +type Disposer = () => void; + +export class KubeconfigSyncManager extends Singleton { + protected sources = observable.map>, Disposer]>(); + protected syncing = false; + protected syncListDisposer?: Disposer; + + protected getSyncName(file: string): string { + return `lens:kube-sync:${file}`; + } + + @action + startSync(port: number): void { + if (this.syncing) { + return; + } + + logger.info(`${logPrefix} starting requested syncs`); + + for (const syncEntry of UserStore.getInstance().syncKubeconfigEntries) { + this.startNewSync(syncEntry, port); + } + + this.syncing = true; + + this.syncListDisposer = UserStore.getInstance().syncKubeconfigEntries.observe(change => { + switch (change.type) { + case "add": + this.startNewSync(change.newValue, port); + break; + case "delete": + this.stopOldSync(change.oldValue); + break; + } + }); + } + + @action + stopSync() { + this.syncListDisposer?.(); + + for (const filePath of this.sources.keys()) { + this.stopOldSync(filePath); + } + + this.syncing = false; + } + + @action + protected startNewSync(filePath: string, port: number): void { + if (this.sources.has(filePath)) { + // don't start a new sync if we already have one + return; + } + + logger.info(`${logPrefix} starting sync of file`, { filePath }); + const changeSet = watchFileChanges(filePath, port); + + this.sources.set(filePath, changeSet); + catalogEntityRegistry.addComputedSource(this.getSyncName(filePath), changeSet[0]); + } + + @action + protected stopOldSync(filePath: string): void { + if (!this.sources.has(filePath)) { + // already stopped + return; + } + + logger.info(`${logPrefix} stopping sync of file`, { filePath }); + this.sources.delete(filePath); + catalogEntityRegistry.removeSource(this.getSyncName(filePath)); + } +} + +// 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"); + } + + const entity = catalogEntityFromCluster(cluster); + + entity.metadata.labels.KUBECONFIG_SYNC = 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; + + readStream + .on("data", chunk => bufs.push(chunk)) + .on("close", () => closed = true) + .on("end", () => { + if (!closed) { + computeDiff(Buffer.concat(bufs).toString("utf-8"), source, port, filePath); + } + }); + + return () => { + 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("data"); + readStream.removeAllListeners("close"); + readStream.removeAllListeners("end"); + }; +} + +function watchFileChanges(filePath: string, port: number): [IComputedValue, Disposer] { + const watcher = watch(filePath, { + followSymlinks: true, + depth: 0, // shallow + disableGlobbing: true, + alwaysStat: false, + ignoreInitial: false, + }); + 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)(); + }); + + 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/migrations/user-store/5.0.0-alpha.2.ts b/src/migrations/user-store/5.0.0-alpha.2.ts new file mode 100644 index 0000000000..a8800d7dae --- /dev/null +++ b/src/migrations/user-store/5.0.0-alpha.2.ts @@ -0,0 +1,18 @@ +// Switch representation of hiddenTableColumns in store +import { migration } from "../migration-wrapper"; + +export default migration({ + version: "5.0.0-alpha.2", + run(store) { + const preferences = store.get("preferences"); + const oldHiddenTableColumns: Record = preferences?.hiddenTableColumns; + + if (!oldHiddenTableColumns) { + return; + } + + preferences.hiddenTableColumns = Object.entries(oldHiddenTableColumns); + + store.get("preferences", preferences); + } +}); diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index 8be97cc86d..c1851db704 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 { createMuiTheme, ThemeProvider } from "@material-ui/core"; /** * If this is a development buid, wait a second to attach @@ -92,9 +93,41 @@ export async function bootstrap(App: AppComponent) { }); render(<> {isMac &&
} - + {DefaultProps(App)} , rootElem); } +const defaultTheme = createMuiTheme({ + props: { + MuiIconButton: { + color: "inherit", + }, + MuiSvgIcon: { + fontSize: "inherit", + }, + MuiTooltip: { + placement: "top", + } + }, + overrides: { + MuiIconButton: { + root: { + "&:hover": { + color: "var(--iconActiveColor)", + backgroundColor: "var(--iconActiveBackground)", + } + } + } + }, +}); + +function DefaultProps(App: AppComponent) { + return ( + + + + ); +} + // run bootstrap(process.isMainFrame ? LensApp : App); diff --git a/src/renderer/components/+add-cluster/add-cluster.tsx b/src/renderer/components/+add-cluster/add-cluster.tsx index 9a662636b0..0492565e66 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..35194408cd --- /dev/null +++ b/src/renderer/components/+preferences/kubeconfig-syncs.tsx @@ -0,0 +1,174 @@ + + +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, 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 { + filePath: string; + info: SyncInfo; +} + +async function getMapEntry(entry: KubeconfigSyncEntry): Promise<[string, SyncInfo]> { + try { + // stat follows the stat(2) linux syscall spec, namely it follows symlinks + const stats = await fse.stat(entry.filePath); + + if (stats.isFile()) { + return [entry.filePath, { type: "file" }]; + } + + if (stats.isDirectory()) { + return [entry.filePath, { type: "folder" }]; + } + + logger.warn("[KubeconfigSyncs]: unknown stat entry", { stats }); + + return [entry.filePath, { type: "unknown" }]; + } catch (error) { + logger.warn(`[KubeconfigSyncs]: failed to stat entry: ${error}`, { error }); + + return [entry.filePath, { type: "unknown" }]; + } +} + +@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 => getMapEntry({ filePath }), + ), + ); + + this.syncs.replace(mapEntries); + this.loaded = true; + + disposeOnUnmount(this, [ + reaction(() => Array.from(this.syncs.keys()), syncs => { + UserStore.getInstance().syncKubeconfigEntries.replace(syncs); + }) + ]); + } + + @computed get syncsList(): Entry[] | undefined { + if (!this.loaded) { + return undefined; + } + + return Array.from(this.syncs.entries(), ([filePath, info]) => ({ filePath, info })); + } + + @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..ccf231e9dd 100644 --- a/src/renderer/components/+preferences/preferences.scss +++ b/src/renderer/components/+preferences/preferences.scss @@ -1,2 +1,28 @@ .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); + } + + .description { + font-family: monospace; + font-size: inherit; + } + + .action { + font-size: inherit; + } + } + } +} 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/components/app.scss b/src/renderer/components/app.scss index 50cb9c1e38..dda907b889 100755 --- a/src/renderer/components/app.scss +++ b/src/renderer/components/app.scss @@ -55,7 +55,7 @@ } html { - font-size: 62.5%; // 1 rem == 10px + font-size: var(--font-size); color: $textColorPrimary; background-color: $mainBackground; --flex-gap: #{$padding}; diff --git a/yarn.lock b/yarn.lock index f1704da1c9..8550442574 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"