diff --git a/package.json b/package.json index fcd63f8fb3..25de4d10a4 100644 --- a/package.json +++ b/package.json @@ -203,6 +203,7 @@ "handlebars": "^4.7.7", "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", @@ -273,7 +274,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 5117e8d603..3b87f53670 100644 --- a/src/common/__tests__/cluster-store.test.ts +++ b/src/common/__tests__/cluster-store.test.ts @@ -22,8 +22,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"; @@ -54,6 +56,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: { @@ -102,7 +113,7 @@ describe("empty config", () => { icon: "data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5", clusterName: "minikube" }, - kubeConfigPath: ClusterStore.embedCustomKubeConfig("foo", kubeconfig) + kubeConfigPath: embed("foo", kubeconfig) }) ); }); @@ -130,7 +141,7 @@ describe("empty config", () => { preferences: { clusterName: "prod" }, - kubeConfigPath: ClusterStore.embedCustomKubeConfig("prod", kubeconfig) + kubeConfigPath: embed("prod", kubeconfig) }), new Cluster({ id: "dev", @@ -138,7 +149,7 @@ describe("empty config", () => { preferences: { clusterName: "dev" }, - kubeConfigPath: ClusterStore.embedCustomKubeConfig("dev", kubeconfig) + kubeConfigPath: embed("dev", kubeconfig) }) ); }); @@ -149,7 +160,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"); }); @@ -160,6 +171,7 @@ describe("config with existing clusters", () => { beforeEach(() => { ClusterStore.resetInstance(); const mockOpts = { + "temp-kube-config": kubeconfig, "tmp": { "lens-cluster-store.json": JSON.stringify({ __internal__: { @@ -170,20 +182,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", @@ -256,6 +268,8 @@ users: ClusterStore.resetInstance(); const mockOpts = { + "invalid-kube-config": invalidKubeconfig, + "valid-kube-config": kubeconfig, "tmp": { "lens-cluster-store.json": JSON.stringify({ __internal__: { @@ -266,14 +280,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 98167b91d4..5d2bd35344 100644 --- a/src/common/__tests__/kube-helpers.test.ts +++ b/src/common/__tests__/kube-helpers.test.ts @@ -20,7 +20,7 @@ */ import { KubeConfig } from "@kubernetes/client-node"; -import { validateKubeConfig, loadConfig, getNodeWarningConditions } from "../kube-helpers"; +import { validateKubeConfig, loadConfigFromString, getNodeWarningConditions } from "../kube-helpers"; const kubeconfig = ` apiVersion: v1 @@ -59,8 +59,6 @@ users: command: foo `; -const kc = new KubeConfig(); - interface kubeconfig { apiVersion: string, clusters: [{ @@ -88,6 +86,8 @@ let mockKubeConfig: kubeconfig; describe("kube helpers", () => { describe("validateKubeconfig", () => { + const kc = new KubeConfig(); + beforeAll(() => { kc.loadFromString(kubeconfig); }); @@ -164,12 +164,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(); }); }); @@ -200,17 +200,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); }); }); @@ -243,40 +243,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 daa515cf8c..465c598bb0 100644 --- a/src/common/__tests__/user-store.test.ts +++ b/src/common/__tests__/user-store.test.ts @@ -66,19 +66,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 9d8bc864cb..60968efd06 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -26,11 +26,9 @@ import { action, comparer, computed, makeObservable, observable, reaction } from 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 type { KubeConfig } from "@kubernetes/client-node"; import { handleRequest, requestMain, subscribeToBroadcast, unsubscribeAllFromBroadcast } from "./ipc"; import { disposer, noop, toJS } from "./utils"; @@ -116,19 +114,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 clusters = observable.map(); @observable removedClusters = observable.map(); diff --git a/src/common/kube-helpers.ts b/src/common/kube-helpers.ts index 737c767cf2..aff05e68c6 100644 --- a/src/common/kube-helpers.ts +++ b/src/common/kube-helpers.ts @@ -28,6 +28,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; @@ -37,50 +39,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 { @@ -92,67 +152,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 { @@ -230,7 +267,7 @@ export function getNodeWarningConditions(node: V1Node) { * * Note: This function returns an error instead of throwing it, returning `undefined` if the validation passes */ -export function validateKubeConfig(config: KubeConfig, contextName: string, validationOpts: KubeConfigValidationOpts = {}): Error | void { +export function validateKubeConfig(config: KubeConfig, contextName: string, validationOpts: KubeConfigValidationOpts = {}): Error | undefined { try { // we only receive a single context, cluster & user object here so lets validate them as this // will be called when we add a new cluster to Lens @@ -267,6 +304,8 @@ export function validateKubeConfig(config: KubeConfig, contextName: string, vali return new ExecValidationNotFoundError(execCommand, isAbsolute); } } + + return undefined; } catch (error) { return error; } diff --git a/src/common/user-store.ts b/src/common/user-store.ts index 4722dbb367..f7ab221bb8 100644 --- a/src/common/user-store.ts +++ b/src/common/user-store.ts @@ -22,24 +22,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, makeObservable } 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, toJS } from "../renderer/utils"; export interface UserStoreModel { - kubeConfigPath: string; lastSeenAppVersion: string; - seenContexts: string[]; preferences: UserPreferencesModel; } @@ -47,7 +42,7 @@ export interface KubeconfigSyncEntry extends KubeconfigSyncValue { filePath: string; } -export interface KubeconfigSyncValue {} +export interface KubeconfigSyncValue { } export interface UserPreferencesModel { httpsProxy?: string; @@ -77,13 +72,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; @observable colorTheme = UserStore.defaultTheme; @@ -121,10 +109,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 => { @@ -180,15 +164,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; @@ -206,44 +181,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; } @@ -287,9 +232,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 b1980d3f79..cc33ab332e 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -21,7 +21,9 @@ // 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"; @@ -39,8 +41,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/saveToAppFiles.ts b/src/common/utils/paths.ts similarity index 68% rename from src/common/utils/saveToAppFiles.ts rename to src/common/utils/paths.ts index b957cdb38e..391e1392f2 100644 --- a/src/common/utils/saveToAppFiles.ts +++ b/src/common/utils/paths.ts @@ -19,17 +19,17 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -// 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 type { WriteFileOptions } from "fs"; +import os from "os"; -export function saveToAppFiles(filePath: string, contents: any, options?: WriteFileOptions): string { - const absPath = path.resolve((app || remote.app).getPath("userData"), filePath); +function resolveTilde(filePath: string) { + if (filePath[0] === "~" && (filePath[1] === "/" || filePath.length === 1)) { + return filePath.replace("~", os.homedir()); + } - ensureDirSync(path.dirname(absPath)); - writeFileSync(absPath, contents, options); - - return absPath; + return filePath; +} + +export function resolvePath(filePath: string): string { + return path.resolve(resolveTilde(filePath)); } diff --git a/src/main/catalog-sources/kubeconfig-sync.ts b/src/main/catalog-sources/kubeconfig-sync.ts index c4650644da..af6d7e150d 100644 --- a/src/main/catalog-sources/kubeconfig-sync.ts +++ b/src/main/catalog-sources/kubeconfig-sync.ts @@ -29,7 +29,7 @@ import type stream from "stream"; import { Disposer, ExtendedObservableMap, iter, Singleton } from "../../common/utils"; import logger from "../logger"; import type { 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"; @@ -130,18 +130,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, }); } } @@ -156,7 +154,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 e5db4adbb3..df0da0a552 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -27,7 +27,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 { apiResourceRecord, apiResources, KubeApiResource, KubeResource } from "../common/rbac"; import logger from "./logger"; import { VersionDetector } from "./cluster-detectors/version-detector"; @@ -258,14 +258,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 @@ -470,17 +470,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 f06ee014c7..fc2e14a3cb 100644 --- a/src/main/kubeconfig-manager.ts +++ b/src/main/kubeconfig-manager.ts @@ -25,7 +25,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"; @@ -86,9 +86,9 @@ export class KubeconfigManager { */ protected async createProxyKubeconfig(): Promise { const { configDir, cluster } = this; - const { contextName, kubeConfigPath, id } = cluster; - const tempFile = path.normalize(path.join(configDir, `kubeconfig-${id}`)); - const kubeConfig = loadConfig(kubeConfigPath); + const { contextName, id } = cluster; + const tempFile = path.join(configDir, `kubeconfig-${id}`); + 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 df51510f23..5339d5232b 100644 --- a/src/migrations/cluster-store/3.6.0-beta.1.ts +++ b/src/migrations/cluster-store/3.6.0-beta.1.ts @@ -27,7 +27,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", @@ -46,9 +46,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 f8ed7129b1..1a0f7e0b17 100644 --- a/src/renderer/api/kube-watch-api.ts +++ b/src/renderer/api/kube-watch-api.ts @@ -26,8 +26,8 @@ import type { KubeObjectStore } from "../kube-object.store"; import type { ClusterContext } from "../components/context"; import plimit from "p-limit"; -import { comparer, IReactionDisposer, observable, reaction, makeObservable } from "mobx"; -import { autoBind, noop } from "../utils"; +import { comparer, observable, reaction, makeObservable } from "mobx"; +import { autoBind, Disposer, noop } from "../utils"; import type { KubeApi } from "./kube-api"; import type { KubeJsonApiData } from "./kube-json-api"; import { isDebugging, isProduction } from "../../common/vars"; @@ -80,7 +80,7 @@ export class KubeWatchApi { }; } - subscribeStores(stores: KubeObjectStore[], opts: IKubeWatchSubscribeStoreOptions = {}): () => void { + subscribeStores(stores: KubeObjectStore[], opts: IKubeWatchSubscribeStoreOptions = {}): Disposer { const { preload = true, waitUntilLoaded = true, loadOnce = false, } = opts; const subscribingNamespaces = opts.namespaces ?? this.context?.allNamespaces ?? []; const unsubscribeList: Function[] = []; @@ -88,7 +88,7 @@ export class KubeWatchApi { const load = (namespaces = subscribingNamespaces) => this.preloadStores(stores, { namespaces, loadOnce }); let preloading = preload && load(); - let cancelReloading: IReactionDisposer = noop; + let cancelReloading: Disposer = noop; const subscribe = () => { if (isUnsubscribed) return; diff --git a/src/renderer/components/+add-cluster/add-cluster.tsx b/src/renderer/components/+add-cluster/add-cluster.tsx index 9c91550d47..3479fe0426 100644 --- a/src/renderer/components/+add-cluster/add-cluster.tsx +++ b/src/renderer/components/+add-cluster/add-cluster.tsx @@ -20,35 +20,48 @@ */ import "./add-cluster.scss"; -import React from "react"; + +import type { KubeConfig } from "@kubernetes/client-node"; +import fse from "fs-extra"; +import { debounce } from "lodash"; +import { action, computed, observable, makeObservable } from "mobx"; import { observer } from "mobx-react"; -import { action, observable, runInAction, makeObservable } from "mobx"; -import { KubeConfig } from "@kubernetes/client-node"; +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 { 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 { Notifications } from "../notifications"; + +interface Option { + config: KubeConfig; + error?: string; +} + +function getContexts(config: KubeConfig): Map { + return new Map( + splitConfig(config) + .map(({ config, error }) => [config.currentContext, { + config, + error, + }]) + ); +} + @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 errorText: string; constructor(props: {}) { super(props); @@ -59,159 +72,75 @@ export class AddCluster extends React.Component { appEventBus.emit({ name: "cluster-add", action: "start" }); } - componentWillUnmount() { - UserStore.getInstance().markNewContextsAsSeen(); + @computed get allErrors(): string[] { + return [ + this.errorText, + ...iter.map(this.kubeContexts.values(), ({ error }) => error) + ].filter(Boolean); } @action - refreshContexts() { - this.kubeContexts.clear(); + refreshContexts = debounce(() => { + const { config, error } = loadConfigFromString(this.customConfig.trim() || "{}"); - try { - this.error = ""; - const contexts = this.getContexts(loadConfig(this.customConfig || "{}")); - - console.log(contexts); - - this.kubeContexts.replace(contexts); - } catch (err) { - this.error = String(err); - } - } - - getContexts(config: KubeConfig): Map { - const contexts = new Map(); - - splitConfig(config).forEach(config => { - contexts.set(config.currentContext, config); - }); - - return contexts; - } + this.kubeContexts.replace(getContexts(config)); + this.errorText = error?.toString(); + }, 500); @action - addClusters = (): void => { + addClusters = async () => { + this.isWaiting = true; + appEventBus.emit({ name: "cluster-add", action: "click" }); + 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, this.customConfig.trim(), { encoding: "utf-8", mode: 0o600 }); - if (error) { - this.error = error.toString(); + Notifications.ok(`Successfully added ${this.kubeContexts.size} new cluster(s)`); - if (error instanceof ExecValidationNotFoundError) { - Notifications.error(<>Error while adding cluster(s): {this.error}); - } - } - - 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 navigate(catalogURL()); + } catch (error) { + Notifications.error(`Failed to add clusters: ${error}`); } }; - renderInfo() { + render() { 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. -

- ); - } - - renderKubeConfigSource() { - 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(); }} />
- - ); - } - - render() { - const submitDisabled = this.kubeContexts.size === 0; - - return ( - -

Add Clusters from Kubeconfig

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

HTTP Proxy server. Used for communicating with Kubernetes API.

- this.proxyServer = value} - theme="round-black" - /> - - {"A HTTP proxy server URL (format: http://
:)."} - -
+ {this.allErrors.length > 0 && ( + <> +

KubeConfig Yaml Validation Errors:

+ {...this.allErrors.map(error =>
{error}
)} + )} - {this.error && ( -
{this.error}
- )} -
diff --git a/src/renderer/components/cluster-manager/cluster-status.tsx b/src/renderer/components/cluster-manager/cluster-status.tsx index 2d4ccf3a38..c821f2874e 100644 --- a/src/renderer/components/cluster-manager/cluster-status.tsx +++ b/src/renderer/components/cluster-manager/cluster-status.tsx @@ -19,21 +19,23 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import type { KubeAuthProxyLog } from "../../../main/kube-auth-proxy"; - import "./cluster-status.scss"; -import React from "react"; -import { observer } from "mobx-react"; + import { ipcRenderer } from "electron"; import { computed, observable, makeObservable } from "mobx"; -import { requestMain, subscribeToBroadcast } from "../../../common/ipc"; -import { Icon } from "../icon"; -import { Button } from "../button"; -import { cssNames, IClassName } from "../../utils"; -import type { Cluster } from "../../../main/cluster"; -import { ClusterId, ClusterStore } from "../../../common/cluster-store"; -import { CubeSpinner } from "../spinner"; +import { observer } from "mobx-react"; +import React from "react"; import { clusterActivateHandler } from "../../../common/cluster-ipc"; +import { ClusterId, ClusterStore } from "../../../common/cluster-store"; +import { requestMain, subscribeToBroadcast } from "../../../common/ipc"; +import type { Cluster } from "../../../main/cluster"; +import { cssNames, IClassName } from "../../utils"; +import { Button } from "../button"; +import { Icon } from "../icon"; +import { CubeSpinner } from "../spinner"; +import type { KubeAuthProxyLog } from "../../../main/kube-auth-proxy"; +import { navigate } from "../../navigation"; +import { entitySettingsURL } from "../+entity-settings"; interface Props { className?: IClassName; @@ -82,6 +84,15 @@ export class ClusterStatus extends React.Component { this.isReconnecting = false; }; + manageProxySettings = () => { + navigate(entitySettingsURL({ + params: { + entityId: this.props.clusterId, + }, + fragment: "http-proxy", + })); + }; + renderContent() { const { authOutput, cluster, hasErrors } = this; const failureReason = cluster.failureReason; @@ -89,7 +100,7 @@ export class ClusterStatus extends React.Component { if (!hasErrors || this.isReconnecting) { return ( <> - +
             

{this.isReconnecting ? "Reconnecting..." : "Connecting..."}

{authOutput.map(({ data, error }, index) => { @@ -102,7 +113,7 @@ export class ClusterStatus extends React.Component { return ( <> - +

{cluster.preferences.clusterName}

@@ -121,6 +132,12 @@ export class ClusterStatus extends React.Component { onClick={this.reconnect} waiting={this.isReconnecting} /> +