diff --git a/src/common/__tests__/cluster-store.test.ts b/src/common/__tests__/cluster-store.test.ts index ec8e244fd2..b37d4d2b3b 100644 --- a/src/common/__tests__/cluster-store.test.ts +++ b/src/common/__tests__/cluster-store.test.ts @@ -3,6 +3,9 @@ import mockFs from "mock-fs"; import yaml from "js-yaml"; import { Cluster } from "../../main/cluster"; import { ClusterStore, getClusterIdFromHost } from "../cluster-store"; +import { Console } from "console"; + +console = new Console(process.stdout, process.stderr); // fix mockFS const testDataIcon = fs.readFileSync("test-data/cluster-store-migration-icon.png"); const kubeconfig = ` @@ -292,6 +295,13 @@ users: }); }); +const minimalValidKubeConfig = JSON.stringify({ + apiVersion: "v1", + clusters: [], + users: [], + contexts: [], +}); + describe("pre 2.0 config with an existing cluster", () => { beforeEach(() => { ClusterStore.resetInstance(); @@ -303,7 +313,7 @@ describe("pre 2.0 config with an existing cluster", () => { version: "1.0.0" } }, - cluster1: "kubeconfig content" + cluster1: minimalValidKubeConfig, }) } }; @@ -321,7 +331,7 @@ describe("pre 2.0 config with an existing cluster", () => { it("migrates to modern format with kubeconfig in a file", async () => { const config = clusterStore.clustersList[0].kubeConfigPath; - expect(fs.readFileSync(config, "utf8")).toBe("kubeconfig content"); + expect(fs.readFileSync(config, "utf8")).toContain(`"contexts":[]`); }); }); @@ -375,7 +385,7 @@ describe("pre 2.6.0 config with a cluster icon", () => { } }, cluster1: { - kubeConfig: "foo", + kubeConfig: minimalValidKubeConfig, icon: "icon_path", preferences: { terminalCWD: "/tmp" @@ -417,7 +427,7 @@ describe("for a pre 2.7.0-beta.0 config without a workspace", () => { } }, cluster1: { - kubeConfig: "foo", + kubeConfig: minimalValidKubeConfig, preferences: { terminalCWD: "/tmp" } @@ -451,7 +461,7 @@ describe("pre 3.6.0-beta.1 config with an existing cluster", () => { clusters: [ { id: "cluster1", - kubeConfig: "kubeconfig content", + kubeConfig: minimalValidKubeConfig, contextName: "cluster", preferences: { icon: "store://icon_path", @@ -476,7 +486,7 @@ describe("pre 3.6.0-beta.1 config with an existing cluster", () => { it("migrates to modern format with kubeconfig in a file", async () => { const config = clusterStore.clustersList[0].kubeConfigPath; - expect(fs.readFileSync(config, "utf8")).toBe("kubeconfig content"); + expect(fs.readFileSync(config, "utf8")).toBe(minimalValidKubeConfig); }); it("migrates to modern format with icon not in file", async () => { diff --git a/src/common/__tests__/kube-helpers.test.ts b/src/common/__tests__/kube-helpers.test.ts index a782772d34..ddecc6bde8 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 } from "../kube-helpers"; +import { validateKubeConfig, loadConfig } from "../kube-helpers"; const kubeconfig = ` apiVersion: v1 @@ -40,61 +40,214 @@ users: const kc = new KubeConfig(); -describe("validateKubeconfig", () => { - beforeAll(() => { - kc.loadFromString(kubeconfig); - }); - describe("with default validation options", () => { - describe("with valid kubeconfig", () => { - it("does not raise exceptions", () => { - expect(() => { validateKubeConfig(kc, "valid");}).not.toThrow(); - }); +interface kubeconfig { + apiVersion: string, + clusters: [{ + name: string, + cluster: { + server: string + } + }], + contexts: [{ + context: { + cluster: string, + user: string, + }, + name: string + }], + users: [{ + name: string + }], + kind: string, + "current-context": string, + preferences: {} +} + +let mockKubeConfig: kubeconfig; + +describe("kube helpers", () => { + describe("validateKubeconfig", () => { + beforeAll(() => { + kc.loadFromString(kubeconfig); }); - describe("with invalid context object", () => { - it("it raises exception", () => { - expect(() => { validateKubeConfig(kc, "invalid");}).toThrow("No valid context object provided in kubeconfig for context 'invalid'"); + describe("with default validation options", () => { + describe("with valid kubeconfig", () => { + it("does not raise exceptions", () => { + expect(() => { validateKubeConfig(kc, "valid");}).not.toThrow(); + }); + }); + describe("with invalid context object", () => { + it("it raises exception", () => { + expect(() => { validateKubeConfig(kc, "invalid");}).toThrow("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'"); + }); + }); + + describe("with invalid user object", () => { + it("it raises exception", () => { + expect(() => { validateKubeConfig(kc, "invalidUser");}).toThrow("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"); + }); }); }); - describe("with invalid cluster object", () => { - it("it raises exception", () => { - expect(() => { validateKubeConfig(kc, "invalidCluster");}).toThrow("No valid cluster object provided in kubeconfig for context 'invalidCluster'"); + describe("with validateCluster as false", () => { + describe("with invalid cluster object", () => { + it("does not raise exception", () => { + expect(() => { validateKubeConfig(kc, "invalidCluster", { validateCluster: false });}).not.toThrow(); + }); }); }); - describe("with invalid user object", () => { - it("it raises exception", () => { - expect(() => { validateKubeConfig(kc, "invalidUser");}).toThrow("No valid user object provided in kubeconfig for context 'invalidUser'"); + describe("with validateUser as false", () => { + describe("with invalid user object", () => { + it("does not raise exceptions", () => { + expect(() => { validateKubeConfig(kc, "invalidUser", { validateUser: false });}).not.toThrow(); + }); }); }); - 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"); + describe("with validateExec as false", () => { + describe("with invalid exec object", () => { + it("does not raise exceptions", () => { + expect(() => { validateKubeConfig(kc, "invalidExec", { validateExec: false });}).not.toThrow(); + }); }); }); }); - describe("with validateCluster as false", () => { - describe("with invalid cluster object", () => { - it("does not raise exception", () => { - expect(() => { validateKubeConfig(kc, "invalidCluster", { validateCluster: false });}).not.toThrow(); + describe("pre-validate context object in kubeconfig tests", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("Check logger.error() output", () => { + it("invalid yaml string", () => { + const invalidYAMLString = "fancy foo config"; + + expect(() => loadConfig(invalidYAMLString)).toThrowError("must be an object"); + }); + it("empty contexts", () => { + const emptyContexts = `apiVersion: v1\ncontexts:`; + + expect(() => loadConfig(emptyContexts)).not.toThrow(); }); }); - }); - describe("with validateUser as false", () => { - describe("with invalid user object", () => { - it("does not raise excpetions", () => { - expect(() => { validateKubeConfig(kc, "invalidUser", { validateUser: false });}).not.toThrow(); + describe("Check valid kubeconfigs", () => { + beforeEach(() => { + mockKubeConfig = { + apiVersion: "v1", + clusters: [{ + name: "minikube", + cluster: { + server: "https://192.168.64.3:8443", + }, + }], + contexts: [{ + context: { + cluster: "minikube", + user: "minikube", + }, + name: "minikube", + }], + users: [{ + name: "minikube", + }], + kind: "Config", + "current-context": "minikube", + preferences: {}, + }; + }); + + it("single context is ok", async () => { + const kc:KubeConfig = loadConfig(JSON.stringify(mockKubeConfig)); + + expect(kc.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)); + + expect(kc.getCurrentContext()).toBe("minikube"); + expect(kc.contexts.length).toBe(2); }); }); - }); - describe("with validateExec as false", () => { - describe("with invalid exec object", () => { - it("does not raise excpetions", () => { - expect(() => { validateKubeConfig(kc, "invalidExec", { validateExec: false });}).not.toThrow(); + describe("Check invalid kubeconfigs", () => { + beforeEach(() => { + mockKubeConfig = { + apiVersion: "v1", + clusters: [{ + name: "minikube", + cluster: { + server: "https://192.168.64.3:8443", + }, + }], + contexts: [{ + context: { + cluster: "minikube", + user: "minikube", + }, + name: "minikube", + }], + users: [{ + name: "minikube", + }], + kind: "Config", + "current-context": "minikube", + preferences: {}, + }; + }); + + 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)); + + expect(kc.getCurrentContext()).toBe("minikube"); + expect(kc.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)); + + expect(kc.getCurrentContext()).toBe("minikube"); + expect(kc.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)); + + expect(kc.getCurrentContext()).toBe("minikube"); + expect(kc.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)); + + 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"); }); }); }); diff --git a/src/common/kube-helpers.ts b/src/common/kube-helpers.ts index c2a2a8df93..fb4691fedb 100644 --- a/src/common/kube-helpers.ts +++ b/src/common/kube-helpers.ts @@ -6,6 +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"; export type KubeConfigValidationOpts = { validateCluster?: boolean; @@ -23,14 +24,36 @@ function resolveTilde(filePath: string) { return filePath; } +function readResolvedPathSync(filePath: string): string { + return fse.readFileSync(path.resolve(resolveTilde(filePath)), "utf8"); +} + +function checkRawContext(rawContext: any): boolean { + return rawContext.name && rawContext.context?.cluster && rawContext.context?.user; +} + +function loadToOptions(rawYaml: string): any { + const obj = yaml.safeLoad(rawYaml); + + if (typeof obj !== "object" || !obj) { + throw new TypeError("KubeConfig root entry must be an object"); + } + + const { clusters: rawClusters, users: rawUsers, contexts: rawContexts, "current-context": currentContext } = obj; + const clusters = newClusters(rawClusters); + const users = newUsers(rawUsers); + 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); const kc = new KubeConfig(); - if (fse.pathExistsSync(pathOrContent)) { - kc.loadFromFile(path.resolve(resolveTilde(pathOrContent))); - } else { - kc.loadFromString(pathOrContent); - } + // need to load using the kubernetes client to generate a kubeconfig object + kc.loadFromOptions(options); return kc; } @@ -146,7 +169,7 @@ export function podHasIssues(pod: V1Pod) { return ( notReady || pod.status.phase !== "Running" || - pod.spec.priority > 500000 // We're interested in high prio pods events regardless of their running status + pod.spec.priority > 500000 // We're interested in high priority pods events regardless of their running status ); } diff --git a/src/common/user-store.ts b/src/common/user-store.ts index c7ad21f988..ee5ece17d5 100644 --- a/src/common/user-store.ts +++ b/src/common/user-store.ts @@ -2,7 +2,7 @@ import type { ThemeId } from "../renderer/theme.store"; import { app, remote } from "electron"; import semver from "semver"; import { readFile } from "fs-extra"; -import { action, observable, reaction, toJS } from "mobx"; +import { action, computed, observable, reaction, toJS } from "mobx"; import { BaseStore } from "./base-store"; import migrations from "../migrations/user-store"; import { getAppVersion } from "./utils/app-version"; @@ -114,6 +114,10 @@ export class UserStore extends BaseStore { this.kubeConfigPath = kubeConfigDefaultPath; } + @computed get isDefaultKubeConfigPath(): boolean { + return this.kubeConfigPath === kubeConfigDefaultPath; + } + @action async resetTheme() { await this.whenLoaded; diff --git a/src/renderer/components/+add-cluster/add-cluster.tsx b/src/renderer/components/+add-cluster/add-cluster.tsx index b9392e2b95..94980a4d15 100644 --- a/src/renderer/components/+add-cluster/add-cluster.tsx +++ b/src/renderer/components/+add-cluster/add-cluster.tsx @@ -62,9 +62,11 @@ export class AddCluster extends React.Component { this.kubeConfigPath = filePath; userStore.kubeConfigPath = filePath; // save to store } catch (err) { - Notifications.error( -
Can't setup {filePath} as kubeconfig: {String(err)}
- ); + if (!userStore.isDefaultKubeConfigPath) { + Notifications.error( +
Can't setup {filePath} as kubeconfig: {String(err)}
+ ); + } if (throwError) { throw err;