diff --git a/package.json b/package.json index 6a5fac242d..61da145ed6 100644 --- a/package.json +++ b/package.json @@ -181,6 +181,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", @@ -200,6 +201,7 @@ "handlebars": "^4.7.6", "http-proxy": "^1.18.1", "immer": "^8.0.1", + "joi": "^17.4.0", "js-yaml": "^3.14.0", "jsdom": "^16.4.0", "jsonpath": "^1.0.2", @@ -270,7 +272,7 @@ "@types/mini-css-extract-plugin": "^0.9.1", "@types/mock-fs": "^4.10.0", "@types/module-alias": "^2.0.0", - "@types/node": "^12.12.45", + "@types/node": "12.20", "@types/npm": "^2.0.31", "@types/progress-bar-webpack-plugin": "^2.1.0", "@types/proper-lockfile": "^4.1.1", diff --git a/src/common/__tests__/cluster-store.test.ts b/src/common/__tests__/cluster-store.test.ts index 739979c5cf..87896a026c 100644 --- a/src/common/__tests__/cluster-store.test.ts +++ b/src/common/__tests__/cluster-store.test.ts @@ -1,8 +1,10 @@ import fs from "fs"; import mockFs from "mock-fs"; import yaml from "js-yaml"; +import path from "path"; +import fse from "fs-extra"; import { Cluster } from "../../main/cluster"; -import { ClusterStore, getClusterIdFromHost } from "../cluster-store"; +import { ClusterId, ClusterStore, getClusterIdFromHost } from "../cluster-store"; import { Console } from "console"; import { stdout, stderr } from "process"; @@ -33,6 +35,15 @@ users: token: kubeconfig-user-q4lm4:xxxyyyy `; +function embed(clusterId: ClusterId, contents: any): string { + const absPath = ClusterStore.getCustomKubeConfigPath(clusterId); + + fse.ensureDirSync(path.dirname(absPath)); + fse.writeFileSync(absPath, contents, { encoding: "utf-8", mode: 0o600 }); + + return absPath; +} + jest.mock("electron", () => { return { app: { @@ -81,7 +92,7 @@ describe("empty config", () => { icon: "data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5", clusterName: "minikube" }, - kubeConfigPath: ClusterStore.embedCustomKubeConfig("foo", kubeconfig) + kubeConfigPath: embed("foo", kubeconfig) }) ); }); @@ -114,7 +125,7 @@ describe("empty config", () => { preferences: { clusterName: "prod" }, - kubeConfigPath: ClusterStore.embedCustomKubeConfig("prod", kubeconfig) + kubeConfigPath: embed("prod", kubeconfig) }), new Cluster({ id: "dev", @@ -122,7 +133,7 @@ describe("empty config", () => { preferences: { clusterName: "dev" }, - kubeConfigPath: ClusterStore.embedCustomKubeConfig("dev", kubeconfig) + kubeConfigPath: embed("dev", kubeconfig) }) ); }); @@ -133,7 +144,7 @@ describe("empty config", () => { }); it("check if cluster's kubeconfig file saved", () => { - const file = ClusterStore.embedCustomKubeConfig("boo", "kubeconfig"); + const file = embed("boo", "kubeconfig"); expect(fs.readFileSync(file, "utf8")).toBe("kubeconfig"); }); @@ -144,6 +155,7 @@ describe("config with existing clusters", () => { beforeEach(() => { ClusterStore.resetInstance(); const mockOpts = { + "temp-kube-config": kubeconfig, "tmp": { "lens-cluster-store.json": JSON.stringify({ __internal__: { @@ -154,20 +166,20 @@ describe("config with existing clusters", () => { clusters: [ { id: "cluster1", - kubeConfigPath: kubeconfig, + kubeConfigPath: "./temp-kube-config", contextName: "foo", preferences: { terminalCWD: "/foo" }, workspace: "default" }, { id: "cluster2", - kubeConfigPath: kubeconfig, + kubeConfigPath: "./temp-kube-config", contextName: "foo2", preferences: { terminalCWD: "/foo2" } }, { id: "cluster3", - kubeConfigPath: kubeconfig, + kubeConfigPath: "./temp-kube-config", contextName: "foo", preferences: { terminalCWD: "/foo" }, workspace: "foo", @@ -240,6 +252,8 @@ users: ClusterStore.resetInstance(); const mockOpts = { + "invalid-kube-config": invalidKubeconfig, + "valid-kube-config": kubeconfig, "tmp": { "lens-cluster-store.json": JSON.stringify({ __internal__: { @@ -250,14 +264,14 @@ users: clusters: [ { id: "cluster1", - kubeConfigPath: invalidKubeconfig, + kubeConfigPath: "./invalid-kube-config", contextName: "test", preferences: { terminalCWD: "/foo" }, workspace: "foo", }, { id: "cluster2", - kubeConfigPath: kubeconfig, + kubeConfigPath: "./valid-kube-config", contextName: "foo", preferences: { terminalCWD: "/foo" }, workspace: "default" diff --git a/src/common/__tests__/kube-helpers.test.ts b/src/common/__tests__/kube-helpers.test.ts index 808092c89d..7113979b07 100644 --- a/src/common/__tests__/kube-helpers.test.ts +++ b/src/common/__tests__/kube-helpers.test.ts @@ -1,5 +1,5 @@ import { KubeConfig } from "@kubernetes/client-node"; -import { validateKubeConfig, loadConfig, getNodeWarningConditions } from "../kube-helpers"; +import { validateKubeConfig, loadConfigFromString, getNodeWarningConditions } from "../kube-helpers"; const kubeconfig = ` apiVersion: v1 @@ -38,8 +38,6 @@ users: command: foo `; -const kc = new KubeConfig(); - interface kubeconfig { apiVersion: string, clusters: [{ @@ -67,6 +65,8 @@ let mockKubeConfig: kubeconfig; describe("kube helpers", () => { describe("validateKubeconfig", () => { + const kc = new KubeConfig(); + beforeAll(() => { kc.loadFromString(kubeconfig); }); @@ -143,12 +143,12 @@ describe("kube helpers", () => { it("invalid yaml string", () => { const invalidYAMLString = "fancy foo config"; - expect(() => loadConfig(invalidYAMLString)).toThrowError("must be an object"); + expect(loadConfigFromString(invalidYAMLString).error).toBeInstanceOf(Error); }); it("empty contexts", () => { - const emptyContexts = `apiVersion: v1\ncontexts:`; + const emptyContexts = `apiVersion: v1\ncontexts: []`; - expect(() => loadConfig(emptyContexts)).not.toThrow(); + expect(loadConfigFromString(emptyContexts).error).toBeUndefined(); }); }); @@ -179,17 +179,17 @@ describe("kube helpers", () => { }); it("single context is ok", async () => { - const kc:KubeConfig = loadConfig(JSON.stringify(mockKubeConfig)); + const { config } = loadConfigFromString(JSON.stringify(mockKubeConfig)); - expect(kc.getCurrentContext()).toBe("minikube"); + expect(config.getCurrentContext()).toBe("minikube"); }); it("multiple context is ok", async () => { mockKubeConfig.contexts.push({context: {cluster: "cluster-2", user: "cluster-2"}, name: "cluster-2"}); - const kc:KubeConfig = loadConfig(JSON.stringify(mockKubeConfig)); + const { config } = loadConfigFromString(JSON.stringify(mockKubeConfig)); - expect(kc.getCurrentContext()).toBe("minikube"); - expect(kc.contexts.length).toBe(2); + expect(config.getCurrentContext()).toBe("minikube"); + expect(config.contexts.length).toBe(2); }); }); @@ -222,40 +222,40 @@ describe("kube helpers", () => { it("empty name in context causes it to be removed", async () => { mockKubeConfig.contexts.push({context: {cluster: "cluster-2", user: "cluster-2"}, name: ""}); expect(mockKubeConfig.contexts.length).toBe(2); - const kc:KubeConfig = loadConfig(JSON.stringify(mockKubeConfig)); + const { config } = loadConfigFromString(JSON.stringify(mockKubeConfig)); - expect(kc.getCurrentContext()).toBe("minikube"); - expect(kc.contexts.length).toBe(1); + expect(config.getCurrentContext()).toBe("minikube"); + expect(config.contexts.length).toBe(1); }); it("empty cluster in context causes it to be removed", async () => { mockKubeConfig.contexts.push({context: {cluster: "", user: "cluster-2"}, name: "cluster-2"}); expect(mockKubeConfig.contexts.length).toBe(2); - const kc:KubeConfig = loadConfig(JSON.stringify(mockKubeConfig)); + const { config } = loadConfigFromString(JSON.stringify(mockKubeConfig)); - expect(kc.getCurrentContext()).toBe("minikube"); - expect(kc.contexts.length).toBe(1); + expect(config.getCurrentContext()).toBe("minikube"); + expect(config.contexts.length).toBe(1); }); it("empty user in context causes it to be removed", async () => { mockKubeConfig.contexts.push({context: {cluster: "cluster-2", user: ""}, name: "cluster-2"}); expect(mockKubeConfig.contexts.length).toBe(2); - const kc:KubeConfig = loadConfig(JSON.stringify(mockKubeConfig)); + const { config } = loadConfigFromString(JSON.stringify(mockKubeConfig)); - expect(kc.getCurrentContext()).toBe("minikube"); - expect(kc.contexts.length).toBe(1); + expect(config.getCurrentContext()).toBe("minikube"); + expect(config.contexts.length).toBe(1); }); it("invalid context in between valid contexts is removed", async () => { mockKubeConfig.contexts.push({context: {cluster: "cluster-2", user: ""}, name: "cluster-2"}); mockKubeConfig.contexts.push({context: {cluster: "cluster-3", user: "cluster-3"}, name: "cluster-3"}); expect(mockKubeConfig.contexts.length).toBe(3); - const kc:KubeConfig = loadConfig(JSON.stringify(mockKubeConfig)); + const { config } = loadConfigFromString(JSON.stringify(mockKubeConfig)); - expect(kc.getCurrentContext()).toBe("minikube"); - expect(kc.contexts.length).toBe(2); - expect(kc.contexts[0].name).toBe("minikube"); - expect(kc.contexts[1].name).toBe("cluster-3"); + expect(config.getCurrentContext()).toBe("minikube"); + expect(config.contexts.length).toBe(2); + expect(config.contexts[0].name).toBe("minikube"); + expect(config.contexts[1].name).toBe("cluster-3"); }); }); }); diff --git a/src/common/__tests__/user-store.test.ts b/src/common/__tests__/user-store.test.ts index 01edbda09d..1c34ceaa3a 100644 --- a/src/common/__tests__/user-store.test.ts +++ b/src/common/__tests__/user-store.test.ts @@ -45,19 +45,6 @@ describe("user store tests", () => { expect(us.lastSeenAppVersion).toBe("1.2.3"); }); - it("allows adding and listing seen contexts", () => { - const us = UserStore.getInstance(); - - us.seenContexts.add("foo"); - expect(us.seenContexts.size).toBe(1); - - us.seenContexts.add("foo"); - us.seenContexts.add("bar"); - expect(us.seenContexts.size).toBe(2); // check 'foo' isn't added twice - expect(us.seenContexts.has("foo")).toBe(true); - expect(us.seenContexts.has("bar")).toBe(true); - }); - it("allows setting and getting preferences", () => { const us = UserStore.getInstance(); diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index 5ae5378e6f..af4ca2802a 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -5,11 +5,9 @@ import { action, comparer, computed, observable, reaction, toJS } from "mobx"; import { BaseStore } from "./base-store"; import { Cluster, ClusterState } from "../main/cluster"; import migrations from "../migrations/cluster-store"; +import * as uuid from "uuid"; import logger from "../main/logger"; import { appEventBus } from "./event-bus"; -import { dumpConfigYaml } from "./kube-helpers"; -import { saveToAppFiles } from "./utils/saveToAppFiles"; -import { KubeConfig } from "@kubernetes/client-node"; import { handleRequest, requestMain, subscribeToBroadcast, unsubscribeAllFromBroadcast } from "./ipc"; import { ResourceType } from "../renderer/components/cluster-settings/components/cluster-metrics-setting"; import { disposer, noop } from "./utils"; @@ -97,19 +95,10 @@ export class ClusterStore extends BaseStore { return path.resolve((app || remote.app).getPath("userData"), "kubeconfigs"); } - static getCustomKubeConfigPath(clusterId: ClusterId): string { + static getCustomKubeConfigPath(clusterId: ClusterId = uuid.v4()): string { return path.resolve(ClusterStore.storedKubeConfigFolder, clusterId); } - static embedCustomKubeConfig(clusterId: ClusterId, kubeConfig: KubeConfig | string): string { - const filePath = ClusterStore.getCustomKubeConfigPath(clusterId); - const fileContents = typeof kubeConfig == "string" ? kubeConfig : dumpConfigYaml(kubeConfig); - - saveToAppFiles(filePath, fileContents, { mode: 0o600 }); - - return filePath; - } - @observable activeCluster: ClusterId; @observable removedClusters = observable.map(); @observable clusters = observable.map(); diff --git a/src/common/kube-helpers.ts b/src/common/kube-helpers.ts index 35d32a3d8f..c2275e8b51 100644 --- a/src/common/kube-helpers.ts +++ b/src/common/kube-helpers.ts @@ -7,6 +7,8 @@ import logger from "../main/logger"; import commandExists from "command-exists"; import { ExecValidationNotFoundError } from "./custom-errors"; import { Cluster, Context, newClusters, newContexts, newUsers, User } from "@kubernetes/client-node/dist/config_types"; +import { resolvePath } from "./utils"; +import Joi from "joi"; export type KubeConfigValidationOpts = { validateCluster?: boolean; @@ -16,50 +18,108 @@ export type KubeConfigValidationOpts = { export const kubeConfigDefaultPath = path.join(os.homedir(), ".kube", "config"); -function resolveTilde(filePath: string) { - if (filePath[0] === "~" && (filePath[1] === "/" || filePath.length === 1)) { - return filePath.replace("~", os.homedir()); - } +export function loadConfigFromFileSync(filePath: string): ConfigResult { + const content = fse.readFileSync(resolvePath(filePath), "utf-8"); - return filePath; + return loadConfigFromString(content); } -function readResolvedPathSync(filePath: string): string { - return fse.readFileSync(path.resolve(resolveTilde(filePath)), "utf8"); +export async function loadConfigFromFile(filePath: string): Promise { + const content = await fse.readFile(resolvePath(filePath), "utf-8"); + + return loadConfigFromString(content); } -function checkRawCluster(rawCluster: any): boolean { - return Boolean(rawCluster?.name && rawCluster?.cluster?.server); -} +const clusterSchema = Joi.object({ + name: Joi + .string() + .min(1) + .required(), + cluster: Joi + .object({ + server: Joi + .string() + .min(1) + .required(), + }) + .required(), +}); -function checkRawUser(rawUser: any): boolean { - return Boolean(rawUser?.name); -} +const userSchema = Joi.object({ + name: Joi.string() + .min(1) + .required(), +}); -function checkRawContext(rawContext: any): boolean { - return Boolean(rawContext.name && rawContext.context?.cluster && rawContext.context?.user); -} +const contextSchema = Joi.object({ + name: Joi.string() + .min(1) + .required(), + context: Joi.object({ + cluster: Joi.string() + .min(1) + .required(), + user: Joi.string() + .min(1) + .required(), + }), +}); + +const kubeConfigSchema = Joi + .object({ + users: Joi + .array() + .items(userSchema) + .optional(), + clusters: Joi + .array() + .items(clusterSchema) + .optional(), + contexts: Joi + .array() + .items(contextSchema) + .optional(), + "current-context": Joi + .string() + .min(1) + .optional(), + }) + .required(); export interface KubeConfigOptions { clusters: Cluster[]; users: User[]; contexts: Context[]; - currentContext: string; + currentContext?: string; } -function loadToOptions(rawYaml: string): KubeConfigOptions { - const obj = yaml.safeLoad(rawYaml); +export interface OptionsResult { + options: KubeConfigOptions; + error: Joi.ValidationError; +} - if (typeof obj !== "object" || !obj) { - throw new TypeError("KubeConfig root entry must be an object"); - } +function loadToOptions(rawYaml: string): OptionsResult { + const parsed = yaml.safeLoad(rawYaml); + const { error } = kubeConfigSchema.validate(parsed, { + abortEarly: false, + allowUnknown: true, + }); + const { value } = kubeConfigSchema.validate(parsed, { + abortEarly: false, + allowUnknown: true, + stripUnknown: { + arrays: true, + } + }); + const { clusters: rawClusters, users: rawUsers, contexts: rawContexts, "current-context": currentContext } = value ?? {}; + const clusters = newClusters(rawClusters); + const users = newUsers(rawUsers); + const contexts = newContexts(rawContexts); - const { clusters: rawClusters, users: rawUsers, contexts: rawContexts, "current-context": currentContext } = obj; - const clusters = newClusters(rawClusters?.filter(checkRawCluster)); - const users = newUsers(rawUsers?.filter(checkRawUser)); - const contexts = newContexts(rawContexts?.filter(checkRawContext)); - - return { clusters, users, contexts, currentContext }; + return { + options: { clusters, users, contexts, currentContext }, + error, + }; } export function loadFromOptions(options: KubeConfigOptions): KubeConfig { @@ -71,67 +131,44 @@ export function loadFromOptions(options: KubeConfigOptions): KubeConfig { return kc; } -export function loadConfig(pathOrContent?: string): KubeConfig { - return loadConfigFromString( - fse.pathExistsSync(pathOrContent) - ? readResolvedPathSync(pathOrContent) - : pathOrContent - ); +export interface ConfigResult { + config: KubeConfig; + error: Joi.ValidationError; } -export function loadConfigFromString(content: string): KubeConfig { - return loadFromOptions(loadToOptions(content)); +export function loadConfigFromString(content: string): ConfigResult { + const { options, error } = loadToOptions(content); + + return { + config: loadFromOptions(options), + error, + }; } -/** - * KubeConfig is valid when there's at least one of each defined: - * - User - * - Cluster - * - Context - * @param config KubeConfig to check - */ -export function validateConfig(config: KubeConfig | string): KubeConfig { - if (typeof config == "string") { - config = loadConfig(config); - } - logger.debug(`validating kube config: ${JSON.stringify(config)}`); - - if (!config.users || config.users.length == 0) { - throw new Error("No users provided in config"); - } - - if (!config.clusters || config.clusters.length == 0) { - throw new Error("No clusters provided in config"); - } - - if (!config.contexts || config.contexts.length == 0) { - throw new Error("No contexts provided in config"); - } - - return config; +export interface SplitConfigEntry { + config: KubeConfig, + error?: string; } /** * Breaks kube config into several configs. Each context as it own KubeConfig object */ -export function splitConfig(kubeConfig: KubeConfig): KubeConfig[] { - const configs: KubeConfig[] = []; +export function splitConfig(kubeConfig: KubeConfig): SplitConfigEntry[] { + const { contexts = [] } = kubeConfig; - if (!kubeConfig.contexts) { - return configs; - } - kubeConfig.contexts.forEach(ctx => { - const kc = new KubeConfig(); + return contexts.map(context => { + const config = new KubeConfig(); - kc.clusters = [kubeConfig.getCluster(ctx.cluster)].filter(n => n); - kc.users = [kubeConfig.getUser(ctx.user)].filter(n => n); - kc.contexts = [kubeConfig.getContextObject(ctx.name)].filter(n => n); - kc.setCurrentContext(ctx.name); + config.clusters = [kubeConfig.getCluster(context.cluster)].filter(Boolean); + config.users = [kubeConfig.getUser(context.user)].filter(Boolean); + config.contexts = [kubeConfig.getContextObject(context.name)].filter(Boolean); + config.setCurrentContext(context.name); - configs.push(kc); + return { + config, + error: validateKubeConfig(config, context.name)?.toString(), + }; }); - - return configs; } export function dumpConfigYaml(kubeConfig: Partial): string { diff --git a/src/common/user-store.ts b/src/common/user-store.ts index ab07838f9b..b023b010c6 100644 --- a/src/common/user-store.ts +++ b/src/common/user-store.ts @@ -1,24 +1,19 @@ import type { ThemeId } from "../renderer/theme.store"; import { app, remote } from "electron"; import semver from "semver"; -import { readFile } from "fs-extra"; import { action, computed, observable, reaction, toJS } from "mobx"; import moment from "moment-timezone"; import { BaseStore } from "./base-store"; import migrations from "../migrations/user-store"; import { getAppVersion } from "./utils/app-version"; -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"; export interface UserStoreModel { - kubeConfigPath: string; lastSeenAppVersion: string; - seenContexts: string[]; preferences: UserPreferencesModel; } @@ -55,12 +50,6 @@ export class UserStore extends BaseStore { } @observable lastSeenAppVersion = "0.0.0"; - - /** - * used in add-cluster page for providing context - */ - @observable kubeConfigPath = kubeConfigDefaultPath; - @observable seenContexts = observable.set(); @observable newContexts = observable.set(); @observable allowTelemetry = true; @observable allowUntrustedCAs = false; @@ -99,10 +88,6 @@ export class UserStore extends BaseStore { await fileNameMigration(); await super.load(); - // refresh new contexts - await this.refreshNewContexts(); - reaction(() => this.kubeConfigPath, () => this.refreshNewContexts()); - if (app) { // track telemetry availability reaction(() => this.allowTelemetry, allowed => { @@ -158,15 +143,6 @@ export class UserStore extends BaseStore { this.hiddenTableColumns.get(tableId)?.toggle(columnId); } - @action - resetKubeConfigPath() { - this.kubeConfigPath = kubeConfigDefaultPath; - } - - @computed get isDefaultKubeConfigPath(): boolean { - return this.kubeConfigPath === kubeConfigDefaultPath; - } - @action async resetTheme() { await this.whenLoaded; @@ -184,44 +160,14 @@ export class UserStore extends BaseStore { this.localeTimezone = tz; } - protected async refreshNewContexts() { - try { - const kubeConfig = await readFile(this.kubeConfigPath, "utf8"); - - if (kubeConfig) { - this.newContexts.clear(); - loadConfig(kubeConfig).getContexts() - .filter(ctx => ctx.cluster) - .filter(ctx => !this.seenContexts.has(ctx.name)) - .forEach(ctx => this.newContexts.add(ctx.name)); - } - } catch (err) { - logger.error(err); - this.resetKubeConfigPath(); - } - } - - @action - markNewContextsAsSeen() { - const { seenContexts, newContexts } = this; - - this.seenContexts.replace([...seenContexts, ...newContexts]); - this.newContexts.clear(); - } - @action protected async fromStore(data: Partial = {}) { - const { lastSeenAppVersion, seenContexts = [], preferences, kubeConfigPath } = data; + const { lastSeenAppVersion, preferences } = data; if (lastSeenAppVersion) { this.lastSeenAppVersion = lastSeenAppVersion; } - if (kubeConfigPath) { - this.kubeConfigPath = kubeConfigPath; - } - this.seenContexts.replace(seenContexts); - if (!preferences) { return; } @@ -265,9 +211,7 @@ export class UserStore extends BaseStore { } const model: UserStoreModel = { - kubeConfigPath: this.kubeConfigPath, lastSeenAppVersion: this.lastSeenAppVersion, - seenContexts: Array.from(this.seenContexts), preferences: { httpsProxy: toJS(this.httpsProxy), shell: toJS(this.shell), diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index d4467db958..2375a894a0 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -1,6 +1,8 @@ // Common utils (main OR renderer) -export const noop: any = () => { /* empty */ }; +export function noop(...args: T): void { + return void args; +} export * from "./app-version"; export * from "./autobind"; @@ -17,8 +19,8 @@ export * from "./escapeRegExp"; export * from "./extended-map"; export * from "./getRandId"; export * from "./openExternal"; +export * from "./paths"; export * from "./reject-promise"; -export * from "./saveToAppFiles"; export * from "./singleton"; export * from "./splitArray"; export * from "./tar"; diff --git a/src/common/utils/paths.ts b/src/common/utils/paths.ts new file mode 100644 index 0000000000..15d229b312 --- /dev/null +++ b/src/common/utils/paths.ts @@ -0,0 +1,14 @@ +import path from "path"; +import os from "os"; + +function resolveTilde(filePath: string) { + if (filePath[0] === "~" && (filePath[1] === "/" || filePath.length === 1)) { + return filePath.replace("~", os.homedir()); + } + + return filePath; +} + +export function resolvePath(filePath: string): string { + return path.resolve(resolveTilde(filePath)); +} diff --git a/src/common/utils/saveToAppFiles.ts b/src/common/utils/saveToAppFiles.ts deleted file mode 100644 index 87d09290c0..0000000000 --- a/src/common/utils/saveToAppFiles.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Save file to electron app directory (e.g. "/Users/$USER/Library/Application Support/Lens" for MacOS) -import path from "path"; -import { app, remote } from "electron"; -import { ensureDirSync, writeFileSync } from "fs-extra"; -import { WriteFileOptions } from "fs"; - -export function saveToAppFiles(filePath: string, contents: any, options?: WriteFileOptions): string { - const absPath = path.resolve((app || remote.app).getPath("userData"), filePath); - - ensureDirSync(path.dirname(absPath)); - writeFileSync(absPath, contents, options); - - return absPath; -} diff --git a/src/main/catalog-sources/kubeconfig-sync.ts b/src/main/catalog-sources/kubeconfig-sync.ts index f90fb85caf..99bd9a8725 100644 --- a/src/main/catalog-sources/kubeconfig-sync.ts +++ b/src/main/catalog-sources/kubeconfig-sync.ts @@ -7,7 +7,7 @@ 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 { loadConfigFromString, splitConfig } from "../../common/kube-helpers"; import { Cluster } from "../cluster"; import { catalogEntityFromCluster } from "../cluster-manager"; import { UserStore } from "../../common/user-store"; @@ -101,18 +101,16 @@ export class KubeconfigSyncManager extends Singleton { } // exported for testing -export function configToModels(config: KubeConfig, filePath: string): UpdateClusterModel[] { +export function configToModels(rootConfig: KubeConfig, filePath: string): UpdateClusterModel[] { const validConfigs = []; - for (const contextConfig of splitConfig(config)) { - const error = validateKubeConfig(contextConfig, contextConfig.currentContext); - + for (const { config, error } of splitConfig(rootConfig)) { if (error) { - logger.debug(`${logPrefix} context failed validation: ${error}`, { context: contextConfig.currentContext, filePath }); + logger.debug(`${logPrefix} context failed validation: ${error}`, { context: config.currentContext, filePath }); } else { validConfigs.push({ kubeConfigPath: filePath, - contextName: contextConfig.currentContext, + contextName: config.currentContext, }); } } @@ -127,7 +125,13 @@ type RootSource = ObservableMap; export function computeDiff(contents: string, source: RootSource, filePath: string): void { runInAction(() => { try { - const rawModels = configToModels(loadConfigFromString(contents), filePath); + const { config, error } = loadConfigFromString(contents); + + if (error) { + logger.warn(`${logPrefix} encountered errors while loading config: ${error.message}`, { filePath, details: error.details }); + } + + const rawModels = configToModels(config, filePath); const models = new Map(rawModels.map(m => [m.contextName, m])); logger.debug(`${logPrefix} File now has ${models.size} entries`, { filePath }); diff --git a/src/main/cluster.ts b/src/main/cluster.ts index 1b02e79a02..87efa697b7 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -6,7 +6,7 @@ import { ContextHandler } from "./context-handler"; import { AuthorizationV1Api, CoreV1Api, HttpError, KubeConfig, V1ResourceAttributes } from "@kubernetes/client-node"; import { Kubectl } from "./kubectl"; import { KubeconfigManager } from "./kubeconfig-manager"; -import { loadConfig, validateKubeConfig } from "../common/kube-helpers"; +import { loadConfigFromFile, loadConfigFromFileSync, validateKubeConfig } from "../common/kube-helpers"; import { apiResources, KubeApiResource } from "../common/rbac"; import logger from "./logger"; import { VersionDetector } from "./cluster-detectors/version-detector"; @@ -222,14 +222,14 @@ export class Cluster implements ClusterModel, ClusterState { this.id = model.id; this.updateModel(model); - const kubeconfig = this.getKubeconfig(); - const error = validateKubeConfig(kubeconfig, this.contextName, { validateCluster: true, validateUser: false, validateExec: false}); + const { config } = loadConfigFromFileSync(this.kubeConfigPath); + const validationError = validateKubeConfig(config, this.contextName); - if (error) { - throw error; + if (validationError) { + throw validationError; } - this.apiUrl = kubeconfig.getCluster(kubeconfig.getContextObject(this.contextName).cluster).server; + this.apiUrl = config.getCluster(config.getContextObject(this.contextName).cluster).server; if (ipcMain) { // for the time being, until renderer gets its own cluster type @@ -434,17 +434,20 @@ export class Cluster implements ClusterModel, ClusterState { this.allowedResources = await this.getAllowedResources(); } - protected getKubeconfig(): KubeConfig { - return loadConfig(this.kubeConfigPath); + async getKubeconfig(): Promise { + const { config } = await loadConfigFromFile(this.kubeConfigPath); + + return config; } /** * @internal */ async getProxyKubeconfig(): Promise { - const kubeconfigPath = await this.getProxyKubeconfigPath(); + const proxyKCPath = await this.getProxyKubeconfigPath(); + const { config } = await loadConfigFromFile(proxyKCPath); - return loadConfig(kubeconfigPath); + return config; } /** diff --git a/src/main/kubeconfig-manager.ts b/src/main/kubeconfig-manager.ts index d7753bf742..6fd90852f0 100644 --- a/src/main/kubeconfig-manager.ts +++ b/src/main/kubeconfig-manager.ts @@ -4,7 +4,7 @@ import type { ContextHandler } from "./context-handler"; import { app } from "electron"; import path from "path"; import fs from "fs-extra"; -import { dumpConfigYaml, loadConfig } from "../common/kube-helpers"; +import { dumpConfigYaml } from "../common/kube-helpers"; import logger from "./logger"; import { LensProxy } from "./proxy/lens-proxy"; @@ -64,9 +64,9 @@ export class KubeconfigManager { */ protected async createProxyKubeconfig(): Promise { const { configDir, cluster } = this; - const { contextName, kubeConfigPath, id } = cluster; + const { contextName, id } = cluster; const tempFile = path.join(configDir, `kubeconfig-${id}`); - const kubeConfig = loadConfig(kubeConfigPath); + const kubeConfig = await cluster.getKubeconfig(); const proxyConfig: Partial = { currentContext: contextName, clusters: [ diff --git a/src/migrations/cluster-store/3.6.0-beta.1.ts b/src/migrations/cluster-store/3.6.0-beta.1.ts index ca2d0ccbed..3934d24266 100644 --- a/src/migrations/cluster-store/3.6.0-beta.1.ts +++ b/src/migrations/cluster-store/3.6.0-beta.1.ts @@ -6,7 +6,7 @@ import { app, remote } from "electron"; import { migration } from "../migration-wrapper"; import fse from "fs-extra"; import { ClusterModel, ClusterStore } from "../../common/cluster-store"; -import { loadConfig } from "../../common/kube-helpers"; +import { loadConfigFromFileSync } from "../../common/kube-helpers"; export default migration({ version: "3.6.0-beta.1", @@ -25,9 +25,13 @@ export default migration({ * migrate kubeconfig */ try { + const absPath = ClusterStore.getCustomKubeConfigPath(cluster.id); + + fse.ensureDirSync(path.dirname(absPath)); + fse.writeFileSync(absPath, cluster.kubeConfig, { encoding: "utf-8", mode: 0o600 }); // take the embedded kubeconfig and dump it into a file - cluster.kubeConfigPath = ClusterStore.embedCustomKubeConfig(cluster.id, cluster.kubeConfig); - cluster.contextName = loadConfig(cluster.kubeConfigPath).getCurrentContext(); + cluster.kubeConfigPath = absPath; + cluster.contextName = loadConfigFromFileSync(cluster.kubeConfigPath).config.getCurrentContext(); delete cluster.kubeConfig; } catch (error) { diff --git a/src/renderer/api/kube-watch-api.ts b/src/renderer/api/kube-watch-api.ts index f2f50193db..eba961fc59 100644 --- a/src/renderer/api/kube-watch-api.ts +++ b/src/renderer/api/kube-watch-api.ts @@ -5,7 +5,7 @@ import type { KubeObjectStore } from "../kube-object.store"; import type { ClusterContext } from "../components/context"; import plimit from "p-limit"; -import { comparer, IReactionDisposer, observable, reaction, when } from "mobx"; +import { comparer, observable, reaction, when } from "mobx"; import { autobind, noop } from "../utils"; import { KubeApi } from "./kube-api"; import { KubeJsonApiData } from "./kube-json-api"; @@ -65,7 +65,7 @@ export class KubeWatchApi { const load = (namespaces = subscribingNamespaces) => this.preloadStores(stores, { namespaces, loadOnce }); let preloading = preload && load(); - let cancelReloading: IReactionDisposer = noop; + let cancelReloading: () => void = noop; const subscribe = () => { if (isUnsubscribed) return; diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index 43b1c170eb..db206a7324 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -23,11 +23,11 @@ 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 - * Chrome Debugger to renderer process - * https://stackoverflow.com/questions/52844870/debugging-electron-renderer-process-with-vscode - */ + + + + + async function attachChromeDebugger() { if (isDevelopment) { await delay(1000); diff --git a/src/renderer/components/+add-cluster/add-cluster.tsx b/src/renderer/components/+add-cluster/add-cluster.tsx index b6d24f46e5..c5778beb0a 100644 --- a/src/renderer/components/+add-cluster/add-cluster.tsx +++ b/src/renderer/components/+add-cluster/add-cluster.tsx @@ -1,166 +1,170 @@ import "./add-cluster.scss"; -import React from "react"; -import { observer } from "mobx-react"; -import { action, observable, runInAction } from "mobx"; + import { KubeConfig } from "@kubernetes/client-node"; +import { IconButton, List, ListItem, ListItemSecondaryAction, ListItemText, Switch } from "@material-ui/core"; +import { KeyboardArrowDown, KeyboardArrowUp } from "@material-ui/icons"; +import fse from "fs-extra"; +import { debounce, every } from "lodash"; +import { action, computed, observable } from "mobx"; +import { observer } from "mobx-react"; +import path from "path"; +import React from "react"; + +import { catalogURL } from "../+catalog"; +import { ClusterStore } from "../../../common/cluster-store"; +import { appEventBus } from "../../../common/event-bus"; +import { dumpConfigYaml, loadConfigFromString, splitConfig } from "../../../common/kube-helpers"; +import { docsUrl } from "../../../common/vars"; +import { navigate } from "../../navigation"; +import { iter } from "../../utils"; import { AceEditor } from "../ace-editor"; import { Button } from "../button"; -import { loadConfig, splitConfig, validateKubeConfig } from "../../../common/kube-helpers"; -import { ClusterStore } from "../../../common/cluster-store"; -import { v4 as uuid } from "uuid"; -import { navigate } from "../../navigation"; -import { UserStore } from "../../../common/user-store"; -import { Notifications } from "../notifications"; -import { ExecValidationNotFoundError } from "../../../common/custom-errors"; -import { appEventBus } from "../../../common/event-bus"; -import { PageLayout } from "../layout/page-layout"; -import { docsUrl } from "../../../common/vars"; -import { catalogURL } from "../+catalog"; -import { preferencesURL } from "../+preferences"; import { Input } from "../input"; +import { PageLayout } from "../layout/page-layout"; +import { Notifications } from "../notifications"; + +interface Option { + config: KubeConfig; + selected: boolean; + error?: string; +} + +function getContexts(config: KubeConfig): Map { + return new Map( + splitConfig(config) + .map(({ config, error }) => [config.currentContext, { + config, + error, + selected: false, + }]) + ); +} + @observer export class AddCluster extends React.Component { - @observable.ref kubeConfigLocal: KubeConfig; - @observable.ref error: React.ReactNode; + @observable kubeContexts = observable.map(); @observable customConfig = ""; @observable proxyServer = ""; @observable isWaiting = false; - @observable showSettings = false; - - kubeContexts = observable.map(); + @observable showProxySettings = false; + @observable errorText: string; componentDidMount() { appEventBus.emit({ name: "cluster-add", action: "start" }); } - componentWillUnmount() { - UserStore.getInstance().markNewContextsAsSeen(); + @computed get selectedContexts(): KubeConfig[] { + return Array.from(this.kubeContexts.values()) + .filter(({ selected }) => selected) + .map(({ config }) => config); + } + + @computed get anySelected(): boolean { + return this.selectedContexts.length > 0; } @action - refreshContexts() { - this.kubeContexts.clear(); + refreshContexts = debounce(() => { + const { config, error } = loadConfigFromString(this.customConfig.trim() || "{}"); - try { - this.error = ""; - const contexts = this.getContexts(loadConfig(this.customConfig || "{}")); + this.kubeContexts.replace(getContexts(config)); + this.errorText = error?.toString(); - console.log(contexts); - - this.kubeContexts.replace(contexts); - } catch (err) { - this.error = String(err); + if (this.kubeContexts.size === 1) { + for (const option of this.kubeContexts.values()) { + option.selected = true; + } } - } + }, 500); - getContexts(config: KubeConfig): Map { - const contexts = new Map(); - - splitConfig(config).forEach(config => { - contexts.set(config.currentContext, config); - }); - - return contexts; - } - - @action - addClusters = (): void => { + saveKubeConfigToDisk = async (context: KubeConfig): Promise => { try { + const absPath = ClusterStore.getCustomKubeConfigPath(); - this.error = ""; - this.isWaiting = true; - appEventBus.emit({ name: "cluster-add", action: "click" }); - const newClusters = Array.from(this.kubeContexts.keys()).filter(context => { - const kubeConfig = this.kubeContexts.get(context); - const error = validateKubeConfig(kubeConfig, context); + await fse.ensureDir(path.dirname(absPath)); + await fse.writeFile(absPath, dumpConfigYaml(context), { encoding: "utf-8", mode: 0o600 }); - if (error) { - this.error = error.toString(); + this.kubeContexts.get(context.currentContext).selected = false; - if (error instanceof ExecValidationNotFoundError) { - Notifications.error(<>Error while adding cluster(s): {this.error}); - } - } + return true; + } catch (error) { + this.kubeContexts.get(context.currentContext).error = error?.toString(); - return Boolean(!error); - }).map(context => { - const clusterId = uuid(); - const kubeConfig = this.kubeContexts.get(context); - const kubeConfigPath = ClusterStore.embedCustomKubeConfig(clusterId, kubeConfig); // save in app-files folder - - return { - id: clusterId, - kubeConfigPath, - contextName: kubeConfig.currentContext, - preferences: { - clusterName: kubeConfig.currentContext, - httpsProxy: this.proxyServer || undefined, - }, - }; - }); - - runInAction(() => { - ClusterStore.getInstance().addClusters(...newClusters); - - Notifications.ok( - <>Successfully imported {newClusters.length} cluster(s) - ); - - navigate(catalogURL()); - }); - this.refreshContexts(); - } catch (err) { - this.error = String(err); - Notifications.error(<>Error while adding cluster(s): {this.error}); - } finally { - this.isWaiting = false; + return false; } }; - renderInfo() { - return ( -

- Paste kubeconfig as a text from the clipboard to the textarea below. - If you want to add clusters from kubeconfigs that exists on filesystem, please add those files (or folders) to kubeconfig sync via navigate(preferencesURL())}>Preferences. - Read more about adding clusters here. -

- ); - } + @action + addClusters = async () => { + if (!this.selectedContexts.length) { + return this.errorText = "Please select at least one cluster context"; + } - renderKubeConfigSource() { + this.errorText = ""; + this.isWaiting = true; + appEventBus.emit({ name: "cluster-add", action: "click" }); + + const results = await Promise.all(iter.map(this.selectedContexts, this.saveKubeConfigToDisk)); + + this.isWaiting = false; + + if (every(results)) { + Notifications.ok(`Successfully added ${results.length} new cluster(s)`); + + return navigate(catalogURL()); + } + + Notifications.error(`Failed to add ${this.selectedContexts.length} cluster(s)`); + }; + + renderContextSelectionEntry = (option: Option) => { + const context = option.config.currentContext; + const id = `context-selection-${context}`; + + return ( + + + + this.kubeContexts.get(context).selected = checked} + inputProps={{ "aria-labelledby": id }} + color="primary" + /> + + + ); + }; + + toggleShowProxySettings = () => { + this.showProxySettings = !this.showProxySettings; + }; + + renderProxySettings() { return ( <> -
- { - this.customConfig = value; - this.refreshContexts(); - }} - /> -
- - ); - } - - render() { - const submitDisabled = this.kubeContexts.size === 0; - - return ( - -

Add Clusters from Kubeconfig

- {this.renderInfo()} - {this.renderKubeConfigSource()} - - {this.showSettings && ( +

+ Proxy settings + + { + this.showProxySettings + ? + : + } + +

+ {this.showProxySettings && (

HTTP Proxy server. Used for communicating with Kubernetes API.

)} - {this.error && ( -
{this.error}
- )} + + ); + } + render() { + return ( + +

Add Clusters from Kubeconfig

+

+ Clusters added here are not merged into the ~/.kube/config file. + Read more about adding clusters here. +

+
+ { + this.customConfig = value; + this.errorText = ""; + this.refreshContexts(); + }} + /> + + Pro-Tip: paste kubeconfig to get available contexts + +
+ {this.errorText &&
{this.errorText}
}
+ + {Array.from(this.kubeContexts.values(), this.renderContextSelectionEntry)} + + {this.renderProxySettings()}
); } diff --git a/yarn.lock b/yarn.lock index f344f16084..81a141415f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1476,12 +1476,17 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.41.tgz#d0b939d94c1d7bd53d04824af45f1139b8c45615" integrity sha512-dueRKfaJL4RTtSa7bWeTK1M+VH+Gns73oCgzvYfHZywRCoPSd8EkXBL0mZ9unPTveBn+D9phZBaxuzpwjWkW0g== +"@types/node@12.20": + version "12.20.11" + resolved "https://registry.yarnpkg.com/@types/node/-/node-12.20.11.tgz#980832cd56efafff8c18aa148c4085eb02a483f4" + integrity sha512-gema+apZ6qLQK7k7F0dGkGCWQYsL0qqKORWOQO6tq46q+x+1C0vbOiOqOwRVlh4RAdbQwV/j/ryr3u5NOG1fPQ== + "@types/node@^10.12.0": version "10.17.24" resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.24.tgz#c57511e3a19c4b5e9692bb2995c40a3a52167944" integrity sha512-5SCfvCxV74kzR3uWgTYiGxrd69TbT1I6+cMx1A5kEly/IVveJBimtAMlXiEyVFn5DvUFewQWxOOiJhlxeQwxgA== -"@types/node@^12.0.12", "@types/node@^12.12.45": +"@types/node@^12.0.12": version "12.12.45" resolved "https://registry.yarnpkg.com/@types/node/-/node-12.12.45.tgz#33d550d6da243652004b00cbf4f15997456a38e3" integrity sha512-9w50wqeS0qQH9bo1iIRcQhDXRxoDzyAqCL5oJG+Nuu7cAoe6omGo+YDE0spAGK5sPrdLDhQLbQxq0DnxyndPKA== @@ -8197,6 +8202,17 @@ joi@^17.3.0: "@sideway/formula" "^3.0.0" "@sideway/pinpoint" "^2.0.0" +joi@^17.4.0: + version "17.4.0" + resolved "https://registry.yarnpkg.com/joi/-/joi-17.4.0.tgz#b5c2277c8519e016316e49ababd41a1908d9ef20" + integrity sha512-F4WiW2xaV6wc1jxete70Rw4V/VuMd6IN+a5ilZsxG4uYtUXWu2kq9W5P2dz30e7Gmw8RCbY/u/uk+dMPma9tAg== + dependencies: + "@hapi/hoek" "^9.0.0" + "@hapi/topo" "^5.0.0" + "@sideway/address" "^4.1.0" + "@sideway/formula" "^3.0.0" + "@sideway/pinpoint" "^2.0.0" + jose@^1.27.1: version "1.27.1" resolved "https://registry.yarnpkg.com/jose/-/jose-1.27.1.tgz#a1de2ecb5b3ae1ae28f0d9d0cc536349ada27ec8"