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

Ignore clusters with invalid kubeconfig (#1956)

* Ignore clusters with invalid kubeconfig

Signed-off-by: Lauri Nevala <lauri.nevala@gmail.com>

* Improve error message

Signed-off-by: Lauri Nevala <lauri.nevala@gmail.com>

* Mark cluster as dead if kubeconfig loading fails

Signed-off-by: Lauri Nevala <lauri.nevala@gmail.com>

* Fix tests

Signed-off-by: Lauri Nevala <lauri.nevala@gmail.com>

* Validate cluster object in kubeconfig when constructing cluster

Signed-off-by: Lauri Nevala <lauri.nevala@gmail.com>

* Add unit tests for validateKubeConfig

Signed-off-by: Lauri Nevala <lauri.nevala@gmail.com>

* Refactor validateKubeconfig unit tests

Signed-off-by: Lauri Nevala <lauri.nevala@gmail.com>

* Extract ValidationOpts type

Signed-off-by: Lauri Nevala <lauri.nevala@gmail.com>

* Add default value to validationOpts param

Signed-off-by: Lauri Nevala <lauri.nevala@gmail.com>

* Change isDead to property

Signed-off-by: Lauri Nevala <lauri.nevala@gmail.com>

* Fix lint issues

Signed-off-by: Lauri Nevala <lauri.nevala@gmail.com>

* Add missing new line

Signed-off-by: Lauri Nevala <lauri.nevala@gmail.com>

* Update validateKubeConfig in-code documentation

Signed-off-by: Lauri Nevala <lauri.nevala@gmail.com>

* Remove isDead property

Signed-off-by: Lauri Nevala <lauri.nevala@gmail.com>

* Display warning notification if invalid kubeconfig detected (#2233)

* Display warning notification if invalid kubeconfig detected

Signed-off-by: Lauri Nevala <lauri.nevala@gmail.com>
This commit is contained in:
Lauri Nevala 2021-03-01 17:30:22 +02:00 committed by GitHub
parent e837e6f1db
commit 4f74b9aabe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 307 additions and 30 deletions

View File

@ -6,6 +6,29 @@ import { ClusterStore, getClusterIdFromHost } from "../cluster-store";
import { workspaceStore } from "../workspace-store";
const testDataIcon = fs.readFileSync("test-data/cluster-store-migration-icon.png");
const kubeconfig = `
apiVersion: v1
clusters:
- cluster:
server: https://localhost
name: test
contexts:
- context:
cluster: test
user: test
name: foo
- context:
cluster: test
user: test
name: foo2
current-context: test
kind: Config
preferences: {}
users:
- name: test
user:
token: kubeconfig-user-q4lm4:xxxyyyy
`;
jest.mock("electron", () => {
return {
@ -47,13 +70,13 @@ describe("empty config", () => {
clusterStore.addCluster(
new Cluster({
id: "foo",
contextName: "minikube",
contextName: "foo",
preferences: {
terminalCWD: "/tmp",
icon: "data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5",
clusterName: "minikube"
},
kubeConfigPath: ClusterStore.embedCustomKubeConfig("foo", "fancy foo config"),
kubeConfigPath: ClusterStore.embedCustomKubeConfig("foo", kubeconfig),
workspace: workspaceStore.currentWorkspaceId
})
);
@ -91,20 +114,20 @@ describe("empty config", () => {
clusterStore.addClusters(
new Cluster({
id: "prod",
contextName: "prod",
contextName: "foo",
preferences: {
clusterName: "prod"
},
kubeConfigPath: ClusterStore.embedCustomKubeConfig("prod", "fancy config"),
kubeConfigPath: ClusterStore.embedCustomKubeConfig("prod", kubeconfig),
workspace: "workstation"
}),
new Cluster({
id: "dev",
contextName: "dev",
contextName: "foo2",
preferences: {
clusterName: "dev"
},
kubeConfigPath: ClusterStore.embedCustomKubeConfig("dev", "fancy config"),
kubeConfigPath: ClusterStore.embedCustomKubeConfig("dev", kubeconfig),
workspace: "workstation"
})
);
@ -177,20 +200,20 @@ describe("config with existing clusters", () => {
clusters: [
{
id: "cluster1",
kubeConfig: "foo",
kubeConfigPath: kubeconfig,
contextName: "foo",
preferences: { terminalCWD: "/foo" },
workspace: "default"
},
{
id: "cluster2",
kubeConfig: "foo2",
kubeConfigPath: kubeconfig,
contextName: "foo2",
preferences: { terminalCWD: "/foo2" }
},
{
id: "cluster3",
kubeConfig: "foo",
kubeConfigPath: kubeconfig,
contextName: "foo",
preferences: { terminalCWD: "/foo" },
workspace: "foo",
@ -247,6 +270,78 @@ describe("config with existing clusters", () => {
});
});
describe("config with invalid cluster kubeconfig", () => {
beforeEach(() => {
const invalidKubeconfig = `
apiVersion: v1
clusters:
- cluster:
server: https://localhost
name: test2
contexts:
- context:
cluster: test
user: test
name: test
current-context: test
kind: Config
preferences: {}
users:
- name: test
user:
token: kubeconfig-user-q4lm4:xxxyyyy
`;
ClusterStore.resetInstance();
const mockOpts = {
"tmp": {
"lens-cluster-store.json": JSON.stringify({
__internal__: {
migrations: {
version: "99.99.99"
}
},
clusters: [
{
id: "cluster1",
kubeConfigPath: invalidKubeconfig,
contextName: "test",
preferences: { terminalCWD: "/foo" },
workspace: "foo",
},
{
id: "cluster2",
kubeConfigPath: kubeconfig,
contextName: "foo",
preferences: { terminalCWD: "/foo" },
workspace: "default"
},
]
})
}
};
mockFs(mockOpts);
clusterStore = ClusterStore.getInstance<ClusterStore>();
return clusterStore.load();
});
afterEach(() => {
mockFs.restore();
});
it("does not enable clusters with invalid kubeconfig", () => {
const storedClusters = clusterStore.clustersList;
expect(storedClusters.length).toBe(2);
expect(storedClusters[0].enabled).toBeFalsy;
expect(storedClusters[1].id).toBe("cluster2");
expect(storedClusters[1].enabled).toBeTruthy;
});
});
describe("pre 2.0 config with an existing cluster", () => {
beforeEach(() => {
ClusterStore.resetInstance();

View File

@ -0,0 +1,101 @@
import { KubeConfig } from "@kubernetes/client-node";
import { validateKubeConfig } from "../kube-helpers";
const kubeconfig = `
apiVersion: v1
clusters:
- cluster:
server: https://localhost
name: test
contexts:
- context:
cluster: test
user: test
name: valid
- context:
cluster: test2
user: test
name: invalidCluster
- context:
cluster: test
user: test2
name: invalidUser
- context:
cluster: test
user: invalidExec
name: invalidExec
current-context: test
kind: Config
preferences: {}
users:
- name: test
user:
exec:
command: echo
- name: invalidExec
user:
exec:
command: foo
`;
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();
});
});
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 validateCluster as false", () => {
describe("with invalid cluster object", () => {
it("does not raise exception", () => {
expect(() => { validateKubeConfig(kc, "invalidCluster", { validateCluster: false });}).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("with validateExec as false", () => {
describe("with invalid exec object", () => {
it("does not raise excpetions", () => {
expect(() => { validateKubeConfig(kc, "invalidExec", { validateExec: false });}).not.toThrow();
});
});
});
});

View File

@ -323,7 +323,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
} else {
cluster = new Cluster(clusterModel);
if (!cluster.isManaged) {
if (!cluster.isManaged && cluster.apiUrl) {
cluster.enabled = true;
}
}
@ -337,7 +337,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
}
});
this.activeCluster = newClusters.has(activeCluster) ? activeCluster : null;
this.activeCluster = newClusters.get(activeCluster)?.enabled ? activeCluster : null;
this.clusters.replace(newClusters);
this.removedClusters.replace(removedClusters);
}

View File

@ -1,3 +1,4 @@
export * from "./ipc";
export * from "./invalid-kubeconfig";
export * from "./update-available";
export * from "./type-enforced-ipc";

View File

@ -0,0 +1,3 @@
export const InvalidKubeconfigChannel = "invalid-kubeconfig";
export type InvalidKubeConfigArgs = [clusterId: string];

View File

@ -7,6 +7,12 @@ import logger from "../main/logger";
import commandExists from "command-exists";
import { ExecValidationNotFoundError } from "./custom-errors";
export type KubeConfigValidationOpts = {
validateCluster?: boolean;
validateUser?: boolean;
validateExec?: boolean;
};
export const kubeConfigDefaultPath = path.join(os.homedir(), ".kube", "config");
function resolveTilde(filePath: string) {
@ -151,27 +157,42 @@ export function getNodeWarningConditions(node: V1Node) {
}
/**
* Validates kubeconfig supplied in the add clusters screen. At present this will just validate
* the User struct, specifically the command passed to the exec substructure.
* Checks if `config` has valid `Context`, `User`, `Cluster`, and `exec` fields (if present when required)
*/
export function validateKubeConfig (config: KubeConfig) {
export function validateKubeConfig (config: KubeConfig, contextName: string, validationOpts: KubeConfigValidationOpts = {}) {
// 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
logger.debug(`validateKubeConfig: validating kubeconfig - ${JSON.stringify(config)}`);
const { validateUser = true, validateCluster = true, validateExec = true } = validationOpts;
const contextObject = config.getContextObject(contextName);
// Validate the Context Object
if (!contextObject) {
throw new Error(`No valid context object provided in kubeconfig for context '${contextName}'`);
}
// Validate the Cluster Object
if (validateCluster && !config.getCluster(contextObject.cluster)) {
throw new Error(`No valid cluster object provided in kubeconfig for context '${contextName}'`);
}
const user = config.getUser(contextObject.user);
// Validate the User Object
const user = config.getCurrentUser();
if (validateUser && !user) {
throw new Error(`No valid user object provided in kubeconfig for context '${contextName}'`);
}
if (user.exec) {
// Validate exec command if present
if (validateExec && user?.exec) {
const execCommand = user.exec["command"];
// check if the command is absolute or not
const isAbsolute = path.isAbsolute(execCommand);
// validate the exec struct in the user object, start with the command field
logger.debug(`validateKubeConfig: validating user exec command - ${JSON.stringify(execCommand)}`);
if (!commandExists.sync(execCommand)) {
logger.debug(`validateKubeConfig: exec command ${String(execCommand)} in kubeconfig ${config.currentContext} not found`);
logger.debug(`validateKubeConfig: exec command ${String(execCommand)} in kubeconfig ${contextName} not found`);
throw new ExecValidationNotFoundError(execCommand, isAbsolute);
}
}

View File

@ -4,12 +4,12 @@ import type { IMetricsReqParams } from "../renderer/api/endpoints/metrics.api";
import type { WorkspaceId } from "../common/workspace-store";
import { action, comparer, computed, observable, reaction, toJS, when } from "mobx";
import { apiKubePrefix } from "../common/vars";
import { broadcastMessage } from "../common/ipc";
import { broadcastMessage, InvalidKubeconfigChannel } from "../common/ipc";
import { ContextHandler } from "./context-handler";
import { AuthorizationV1Api, CoreV1Api, KubeConfig, V1ResourceAttributes } from "@kubernetes/client-node";
import { Kubectl } from "./kubectl";
import { KubeconfigManager } from "./kubeconfig-manager";
import { loadConfig } from "../common/kube-helpers";
import { loadConfig, validateKubeConfig } from "../common/kube-helpers";
import request, { RequestPromiseOptions } from "request-promise-native";
import { apiResources, KubeApiResource } from "../common/rbac";
import logger from "./logger";
@ -177,6 +177,7 @@ export class Cluster implements ClusterModel, ClusterState {
* @observable
*/
@observable isAdmin = false;
/**
* Global watch-api accessibility , e.g. "/api/v1/services?watch=1"
*
@ -256,10 +257,16 @@ export class Cluster implements ClusterModel, ClusterState {
constructor(model: ClusterModel) {
this.updateModel(model);
const kubeconfig = this.getKubeconfig();
if (kubeconfig.getContextObject(this.contextName)) {
try {
const kubeconfig = this.getKubeconfig();
validateKubeConfig(kubeconfig, this.contextName, { validateCluster: true, validateUser: false, validateExec: false});
this.apiUrl = kubeconfig.getCluster(kubeconfig.getContextObject(this.contextName).cluster).server;
} catch(err) {
logger.error(err);
logger.error(`[CLUSTER] Failed to load kubeconfig for the cluster '${this.name || this.contextName}' (context: ${this.contextName}, kubeconfig: ${this.kubeConfigPath}).`);
broadcastMessage(InvalidKubeconfigChannel, model.id);
}
}

View File

@ -147,7 +147,7 @@ export class AddCluster extends React.Component {
try {
const kubeConfig = this.kubeContexts.get(context);
validateKubeConfig(kubeConfig);
validateKubeConfig(kubeConfig, context);
return true;
} catch (err) {

View File

@ -52,7 +52,7 @@ export class CreateResource extends React.Component<Props> {
);
if (errors.length) {
errors.forEach(Notifications.error);
errors.forEach(error => Notifications.error(error));
if (!createdResources.length) throw errors[0];
}
const successMessage = (

View File

@ -21,11 +21,12 @@ export class Notifications extends React.Component {
});
}
static error(message: NotificationMessage) {
static error(message: NotificationMessage, customOpts: Partial<Notification> = {}) {
notificationsStore.add({
message,
timeout: 10000,
status: NotificationStatus.ERROR
status: NotificationStatus.ERROR,
...customOpts
});
}

View File

@ -5,6 +5,7 @@ import { Notifications, notificationsStore } from "../components/notifications";
import { Button } from "../components/button";
import { isMac } from "../../common/vars";
import * as uuid from "uuid";
import { invalidKubeconfigHandler } from "./invalid-kubeconfig-handler";
function sendToBackchannel(backchannel: string, notificationId: string, data: BackchannelArg): void {
notificationsStore.remove(notificationId);
@ -58,4 +59,5 @@ export function registerIpcHandlers() {
listener: UpdateAvailableHandler,
verifier: areArgsUpdateAvailableFromMain,
});
onCorrect(invalidKubeconfigHandler);
}

View File

@ -0,0 +1,46 @@
import React from "react";
import { ipcRenderer, IpcRendererEvent, shell } from "electron";
import { clusterStore } from "../../common/cluster-store";
import { InvalidKubeConfigArgs, InvalidKubeconfigChannel } from "../../common/ipc/invalid-kubeconfig";
import { Notifications, notificationsStore } from "../components/notifications";
import { Button } from "../components/button";
export const invalidKubeconfigHandler = {
source: ipcRenderer,
channel: InvalidKubeconfigChannel,
listener: InvalidKubeconfigListener,
verifier: (args: [unknown]): args is InvalidKubeConfigArgs => {
return args.length === 1 && typeof args[0] === "string" && !!clusterStore.getById(args[0]);
},
};
function InvalidKubeconfigListener(event: IpcRendererEvent, ...[clusterId]: InvalidKubeConfigArgs): void {
const notificationId = `invalid-kubeconfig:${clusterId}`;
const cluster = clusterStore.getById(clusterId);
const contextName = cluster.name !== cluster.contextName ? `(context: ${cluster.contextName})` : "";
Notifications.error(
(
<div className="flex column gaps">
<b>Cluster with Invalid Kubeconfig Detected!</b>
<p>Cluster <b>{cluster.name}</b> has invalid kubeconfig {contextName} and cannot be displayed.
Please fix the <a href="#" onClick={(e) => { e.preventDefault(); shell.showItemInFolder(cluster.kubeConfigPath); }}>kubeconfig</a> manually and restart Lens
or remove the cluster.</p>
<p>Do you want to remove the cluster now?</p>
<div className="flex gaps row align-left box grow">
<Button active outlined label="Remove" onClick={()=> {
clusterStore.removeById(clusterId);
notificationsStore.remove(notificationId);
}} />
<Button active outlined label="Cancel" onClick={() => notificationsStore.remove(notificationId)} />
</div>
</div>
),
{
id: notificationId,
timeout: 0
}
);
}