1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Pre-Validate kubeconfig before making available in Lens (#1078)

This commit is contained in:
steve richards 2021-04-16 16:41:18 +01:00 committed by Sebastian Malton
parent 23c9255b9a
commit e560baa2d0
5 changed files with 244 additions and 52 deletions

View File

@ -3,6 +3,9 @@ import mockFs from "mock-fs";
import yaml from "js-yaml"; import yaml from "js-yaml";
import { Cluster } from "../../main/cluster"; import { Cluster } from "../../main/cluster";
import { ClusterStore, getClusterIdFromHost } from "../cluster-store"; 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 testDataIcon = fs.readFileSync("test-data/cluster-store-migration-icon.png");
const kubeconfig = ` 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", () => { describe("pre 2.0 config with an existing cluster", () => {
beforeEach(() => { beforeEach(() => {
ClusterStore.resetInstance(); ClusterStore.resetInstance();
@ -303,7 +313,7 @@ describe("pre 2.0 config with an existing cluster", () => {
version: "1.0.0" 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 () => { it("migrates to modern format with kubeconfig in a file", async () => {
const config = clusterStore.clustersList[0].kubeConfigPath; 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: { cluster1: {
kubeConfig: "foo", kubeConfig: minimalValidKubeConfig,
icon: "icon_path", icon: "icon_path",
preferences: { preferences: {
terminalCWD: "/tmp" terminalCWD: "/tmp"
@ -417,7 +427,7 @@ describe("for a pre 2.7.0-beta.0 config without a workspace", () => {
} }
}, },
cluster1: { cluster1: {
kubeConfig: "foo", kubeConfig: minimalValidKubeConfig,
preferences: { preferences: {
terminalCWD: "/tmp" terminalCWD: "/tmp"
} }
@ -451,7 +461,7 @@ describe("pre 3.6.0-beta.1 config with an existing cluster", () => {
clusters: [ clusters: [
{ {
id: "cluster1", id: "cluster1",
kubeConfig: "kubeconfig content", kubeConfig: minimalValidKubeConfig,
contextName: "cluster", contextName: "cluster",
preferences: { preferences: {
icon: "store://icon_path", 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 () => { it("migrates to modern format with kubeconfig in a file", async () => {
const config = clusterStore.clustersList[0].kubeConfigPath; 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 () => { it("migrates to modern format with icon not in file", async () => {

View File

@ -1,5 +1,5 @@
import { KubeConfig } from "@kubernetes/client-node"; import { KubeConfig } from "@kubernetes/client-node";
import { validateKubeConfig } from "../kube-helpers"; import { validateKubeConfig, loadConfig } from "../kube-helpers";
const kubeconfig = ` const kubeconfig = `
apiVersion: v1 apiVersion: v1
@ -40,61 +40,214 @@ users:
const kc = new KubeConfig(); const kc = new KubeConfig();
describe("validateKubeconfig", () => { interface kubeconfig {
beforeAll(() => { apiVersion: string,
kc.loadFromString(kubeconfig); clusters: [{
}); name: string,
describe("with default validation options", () => { cluster: {
describe("with valid kubeconfig", () => { server: string
it("does not raise exceptions", () => { }
expect(() => { validateKubeConfig(kc, "valid");}).not.toThrow(); }],
}); 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", () => { describe("with default validation options", () => {
it("it raises exception", () => { describe("with valid kubeconfig", () => {
expect(() => { validateKubeConfig(kc, "invalid");}).toThrow("No valid context object provided in kubeconfig for context 'invalid'"); 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", () => { describe("with validateCluster as false", () => {
it("it raises exception", () => { describe("with invalid cluster object", () => {
expect(() => { validateKubeConfig(kc, "invalidCluster");}).toThrow("No valid cluster object provided in kubeconfig for context 'invalidCluster'"); it("does not raise exception", () => {
expect(() => { validateKubeConfig(kc, "invalidCluster", { validateCluster: false });}).not.toThrow();
});
}); });
}); });
describe("with invalid user object", () => { describe("with validateUser as false", () => {
it("it raises exception", () => { describe("with invalid user object", () => {
expect(() => { validateKubeConfig(kc, "invalidUser");}).toThrow("No valid user object provided in kubeconfig for context 'invalidUser'"); it("does not raise exceptions", () => {
expect(() => { validateKubeConfig(kc, "invalidUser", { validateUser: false });}).not.toThrow();
});
}); });
}); });
describe("with invalid exec command", () => { describe("with validateExec as false", () => {
it("it raises exception", () => { describe("with invalid exec object", () => {
expect(() => { validateKubeConfig(kc, "invalidExec");}).toThrow("User Exec command \"foo\" not found on host. Please ensure binary is found in PATH or use absolute path to binary in Kubeconfig"); it("does not raise exceptions", () => {
expect(() => { validateKubeConfig(kc, "invalidExec", { validateExec: false });}).not.toThrow();
});
}); });
}); });
}); });
describe("with validateCluster as false", () => { describe("pre-validate context object in kubeconfig tests", () => {
describe("with invalid cluster object", () => { beforeEach(() => {
it("does not raise exception", () => { jest.clearAllMocks();
expect(() => { validateKubeConfig(kc, "invalidCluster", { validateCluster: false });}).not.toThrow(); });
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("Check valid kubeconfigs", () => {
describe("with invalid user object", () => { beforeEach(() => {
it("does not raise excpetions", () => { mockKubeConfig = {
expect(() => { validateKubeConfig(kc, "invalidUser", { validateUser: false });}).not.toThrow(); 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("Check invalid kubeconfigs", () => {
describe("with invalid exec object", () => { beforeEach(() => {
it("does not raise excpetions", () => { mockKubeConfig = {
expect(() => { validateKubeConfig(kc, "invalidExec", { validateExec: false });}).not.toThrow(); 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");
}); });
}); });
}); });

View File

@ -6,6 +6,7 @@ import yaml from "js-yaml";
import logger from "../main/logger"; import logger from "../main/logger";
import commandExists from "command-exists"; import commandExists from "command-exists";
import { ExecValidationNotFoundError } from "./custom-errors"; import { ExecValidationNotFoundError } from "./custom-errors";
import { newClusters, newContexts, newUsers } from "@kubernetes/client-node/dist/config_types";
export type KubeConfigValidationOpts = { export type KubeConfigValidationOpts = {
validateCluster?: boolean; validateCluster?: boolean;
@ -23,14 +24,36 @@ function resolveTilde(filePath: string) {
return filePath; 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 { export function loadConfig(pathOrContent?: string): KubeConfig {
const content = fse.pathExistsSync(pathOrContent) ? readResolvedPathSync(pathOrContent) : pathOrContent;
const options = loadToOptions(content);
const kc = new KubeConfig(); const kc = new KubeConfig();
if (fse.pathExistsSync(pathOrContent)) { // need to load using the kubernetes client to generate a kubeconfig object
kc.loadFromFile(path.resolve(resolveTilde(pathOrContent))); kc.loadFromOptions(options);
} else {
kc.loadFromString(pathOrContent);
}
return kc; return kc;
} }
@ -146,7 +169,7 @@ export function podHasIssues(pod: V1Pod) {
return ( return (
notReady || notReady ||
pod.status.phase !== "Running" || 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
); );
} }

View File

@ -2,7 +2,7 @@ import type { ThemeId } from "../renderer/theme.store";
import { app, remote } from "electron"; import { app, remote } from "electron";
import semver from "semver"; import semver from "semver";
import { readFile } from "fs-extra"; 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 { BaseStore } from "./base-store";
import migrations from "../migrations/user-store"; import migrations from "../migrations/user-store";
import { getAppVersion } from "./utils/app-version"; import { getAppVersion } from "./utils/app-version";
@ -114,6 +114,10 @@ export class UserStore extends BaseStore<UserStoreModel> {
this.kubeConfigPath = kubeConfigDefaultPath; this.kubeConfigPath = kubeConfigDefaultPath;
} }
@computed get isDefaultKubeConfigPath(): boolean {
return this.kubeConfigPath === kubeConfigDefaultPath;
}
@action @action
async resetTheme() { async resetTheme() {
await this.whenLoaded; await this.whenLoaded;

View File

@ -62,9 +62,11 @@ export class AddCluster extends React.Component {
this.kubeConfigPath = filePath; this.kubeConfigPath = filePath;
userStore.kubeConfigPath = filePath; // save to store userStore.kubeConfigPath = filePath; // save to store
} catch (err) { } catch (err) {
Notifications.error( if (!userStore.isDefaultKubeConfigPath) {
<div>Can&apos;t setup <code>{filePath}</code> as kubeconfig: {String(err)}</div> Notifications.error(
); <div>Can&apos;t setup <code>{filePath}</code> as kubeconfig: {String(err)}</div>
);
}
if (throwError) { if (throwError) {
throw err; throw err;