Merge branch 'master' into improve-metrics-queries

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>
This commit is contained in:
Jari Kolehmainen committed 2021-04-13 13:21:58 +03:00
commit a1babe7b2a
560 files changed
+23389 -7332

No files matched your search

@@ -0,0 +1,63 @@
import { observable, reaction } from "mobx";
import { WebLink } from "../catalog-entities";
import { CatalogEntityRegistry } from "../catalog-entity-registry";
describe("CatalogEntityRegistry", () => {
let registry: CatalogEntityRegistry;
const entity = new WebLink({
apiVersion: "entity.k8slens.dev/v1alpha1",
kind: "WebLink",
metadata: {
uid: "test",
name: "test-link",
source: "test",
labels: {}
},
spec: {
url: "https://k8slens.dev"
},
status: {
phase: "ok"
}
});
beforeEach(() => {
registry = new CatalogEntityRegistry();
});
describe("addSource", () => {
it ("allows to add an observable source", () => {
const source = observable.array([]);
registry.addSource("test", source);
expect(registry.items.length).toEqual(0);
source.push(entity);
expect(registry.items.length).toEqual(1);
});
it ("added source change triggers reaction", (done) => {
const source = observable.array([]);
registry.addSource("test", source);
reaction(() => registry.items, () => {
done();
});
source.push(entity);
});
});
describe("removeSource", () => {
it ("removes source", () => {
const source = observable.array([]);
registry.addSource("test", source);
source.push(entity);
registry.removeSource("test");
expect(registry.items.length).toEqual(0);
});
});
});
+133 -70
View File
@@ -2,17 +2,40 @@ import fs from "fs";
import mockFs from "mock-fs";
import yaml from "js-yaml";
import { Cluster } from "../../main/cluster";
import { ClusterStore } from "../cluster-store";
import { workspaceStore } from "../workspace-store";
import { ClusterStore, getClusterIdFromHost } from "../cluster-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 {
app: {
getVersion: () => "99.99.99",
getPath: () => "tmp",
getLocale: () => "en"
getLocale: () => "en",
setLoginItemSettings: jest.fn(),
},
ipcMain: {
handle: jest.fn(),
@@ -47,14 +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"),
workspace: workspaceStore.currentWorkspaceId
kubeConfigPath: ClusterStore.embedCustomKubeConfig("foo", kubeconfig)
})
);
});
@@ -68,21 +90,14 @@ describe("empty config", () => {
expect(storedCluster.enabled).toBe(true);
});
it("adds cluster to default workspace", () => {
const storedCluster = clusterStore.getById("foo");
expect(storedCluster.workspace).toBe("default");
});
it("removes cluster from store", async () => {
await clusterStore.removeById("foo");
expect(clusterStore.getById("foo")).toBeUndefined();
expect(clusterStore.getById("foo")).toBeNull();
});
it("sets active cluster", () => {
clusterStore.setActive("foo");
expect(clusterStore.active.id).toBe("foo");
expect(workspaceStore.currentWorkspace.lastActiveClusterId).toBe("foo");
});
});
@@ -91,21 +106,19 @@ describe("empty config", () => {
clusterStore.addClusters(
new Cluster({
id: "prod",
contextName: "prod",
contextName: "foo",
preferences: {
clusterName: "prod"
},
kubeConfigPath: ClusterStore.embedCustomKubeConfig("prod", "fancy config"),
workspace: "workstation"
kubeConfigPath: ClusterStore.embedCustomKubeConfig("prod", kubeconfig)
}),
new Cluster({
id: "dev",
contextName: "dev",
contextName: "foo2",
preferences: {
clusterName: "dev"
},
kubeConfigPath: ClusterStore.embedCustomKubeConfig("dev", "fancy config"),
workspace: "workstation"
kubeConfigPath: ClusterStore.embedCustomKubeConfig("dev", kubeconfig)
})
);
});
@@ -115,51 +128,11 @@ describe("empty config", () => {
expect(clusterStore.clusters.size).toBe(2);
});
it("gets clusters by workspaces", () => {
const wsClusters = clusterStore.getByWorkspaceId("workstation");
const defaultClusters = clusterStore.getByWorkspaceId("default");
expect(defaultClusters.length).toBe(0);
expect(wsClusters.length).toBe(2);
expect(wsClusters[0].id).toBe("prod");
expect(wsClusters[1].id).toBe("dev");
});
it("check if cluster's kubeconfig file saved", () => {
const file = ClusterStore.embedCustomKubeConfig("boo", "kubeconfig");
expect(fs.readFileSync(file, "utf8")).toBe("kubeconfig");
});
it("check if reorderring works for same from and to", () => {
clusterStore.swapIconOrders("workstation", 1, 1);
const clusters = clusterStore.getByWorkspaceId("workstation");
expect(clusters[0].id).toBe("prod");
expect(clusters[0].preferences.iconOrder).toBe(0);
expect(clusters[1].id).toBe("dev");
expect(clusters[1].preferences.iconOrder).toBe(1);
});
it("check if reorderring works for different from and to", () => {
clusterStore.swapIconOrders("workstation", 0, 1);
const clusters = clusterStore.getByWorkspaceId("workstation");
expect(clusters[0].id).toBe("dev");
expect(clusters[0].preferences.iconOrder).toBe(0);
expect(clusters[1].id).toBe("prod");
expect(clusters[1].preferences.iconOrder).toBe(1);
});
it("check if after icon reordering, changing workspaces still works", () => {
clusterStore.swapIconOrders("workstation", 1, 1);
clusterStore.getById("prod").workspace = "default";
expect(clusterStore.getByWorkspaceId("workstation").length).toBe(1);
expect(clusterStore.getByWorkspaceId("default").length).toBe(1);
});
});
});
@@ -177,20 +150,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",
@@ -225,7 +198,7 @@ describe("config with existing clusters", () => {
expect(storedCluster).toBeTruthy();
const storedCluster2 = clusterStore.getById("cluster2");
expect(storedCluster2).toBeUndefined();
expect(storedCluster2).toBeNull();
});
it("allows getting all of the clusters", async () => {
@@ -247,6 +220,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();
@@ -390,12 +435,6 @@ describe("for a pre 2.7.0-beta.0 config without a workspace", () => {
afterEach(() => {
mockFs.restore();
});
it("adds cluster to default workspace", async () => {
const storedClusterData = clusterStore.clustersList[0];
expect(storedClusterData.workspace).toBe("default");
});
});
describe("pre 3.6.0-beta.1 config with an existing cluster", () => {
@@ -446,3 +485,27 @@ describe("pre 3.6.0-beta.1 config with an existing cluster", () => {
expect(icon.startsWith("data:;base64,")).toBe(true);
});
});
describe("getClusterIdFromHost", () => {
const clusterFakeId = "fe540901-0bd6-4f6c-b472-bce1559d7c4a";
it("should return undefined for non cluster frame hosts", () => {
expect(getClusterIdFromHost("localhost:45345")).toBeUndefined();
});
it("should return ClusterId for cluster frame hosts", () => {
expect(getClusterIdFromHost(`${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId);
});
it("should return ClusterId for cluster frame hosts with additional subdomains", () => {
expect(getClusterIdFromHost(`abc.${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId);
expect(getClusterIdFromHost(`abc.def.${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId);
expect(getClusterIdFromHost(`abc.def.ghi.${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId);
expect(getClusterIdFromHost(`abc.def.ghi.jkl.${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId);
expect(getClusterIdFromHost(`abc.def.ghi.jkl.mno.${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId);
expect(getClusterIdFromHost(`abc.def.ghi.jkl.mno.pqr.${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId);
expect(getClusterIdFromHost(`abc.def.ghi.jkl.mno.pqr.stu.${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId);
expect(getClusterIdFromHost(`abc.def.ghi.jkl.mno.pqr.stu.vwx.${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId);
expect(getClusterIdFromHost(`abc.def.ghi.jkl.mno.pqr.stu.vwx.yz.${clusterFakeId}.localhost:59110`)).toBe(clusterFakeId);
});
});
+101
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();
});
});
});
});
+7 -11
View File
@@ -1,7 +1,3 @@
/**
* @jest-environment jsdom
*/
import { SearchStore } from "../search-store";
let searchStore: SearchStore = null;
@@ -29,28 +25,28 @@ describe("search store tests", () => {
expect(searchStore.occurrences).toEqual([]);
});
it("find 3 occurences across 3 lines", () => {
it("find 3 occurrences across 3 lines", () => {
searchStore.onSearch(logs, "172");
expect(searchStore.occurrences).toEqual([0, 1, 2]);
});
it("find occurences within 1 line (case-insensitive)", () => {
it("find occurrences within 1 line (case-insensitive)", () => {
searchStore.onSearch(logs, "Starting");
expect(searchStore.occurrences).toEqual([2, 2]);
});
it("sets overlay index equal to first occurence", () => {
it("sets overlay index equal to first occurrence", () => {
searchStore.onSearch(logs, "Replica");
expect(searchStore.activeOverlayIndex).toBe(0);
});
it("set overlay index to next occurence", () => {
it("set overlay index to next occurrence", () => {
searchStore.onSearch(logs, "172");
searchStore.setNextOverlayActive();
expect(searchStore.activeOverlayIndex).toBe(1);
});
it("sets overlay to last occurence", () => {
it("sets overlay to last occurrence", () => {
searchStore.onSearch(logs, "172");
searchStore.setPrevOverlayActive();
expect(searchStore.activeOverlayIndex).toBe(2);
@@ -62,7 +58,7 @@ describe("search store tests", () => {
});
it("escapes string for using in regex", () => {
const regex = searchStore.escapeRegex("some.interesting-query\\#?()[]");
const regex = SearchStore.escapeRegex("some.interesting-query\\#?()[]");
expect(regex).toBe("some\\.interesting\\-query\\\\\\#\\?\\(\\)\\[\\]");
});
@@ -77,4 +73,4 @@ describe("search store tests", () => {
searchStore.onSearch(logs, "Starting");
expect(searchStore.totalFinds).toBe(2);
});
});
});
+7 -2
View File
@@ -1,3 +1,7 @@
import { Console } from "console";
console = new Console(process.stdout, process.stderr);
import mockFs from "mock-fs";
jest.mock("electron", () => {
@@ -5,7 +9,8 @@ jest.mock("electron", () => {
app: {
getVersion: () => "99.99.99",
getPath: () => "tmp",
getLocale: () => "en"
getLocale: () => "en",
setLoginItemSettings: jest.fn(),
}
};
});
@@ -101,4 +106,4 @@ describe("user store tests", () => {
expect(us.lastSeenAppVersion).toBe("0.0.0");
});
});
});
});
@@ -1,181 +0,0 @@
import mockFs from "mock-fs";
jest.mock("electron", () => {
return {
app: {
getVersion: () => "99.99.99",
getPath: () => "tmp",
getLocale: () => "en"
},
ipcMain: {
handle: jest.fn(),
on: jest.fn()
}
};
});
import { Workspace, WorkspaceStore } from "../workspace-store";
describe("workspace store tests", () => {
describe("for an empty config", () => {
beforeEach(async () => {
WorkspaceStore.resetInstance();
mockFs({ tmp: { "lens-workspace-store.json": "{}" } });
await WorkspaceStore.getInstance<WorkspaceStore>().load();
});
afterEach(() => {
mockFs.restore();
});
it("default workspace should always exist", () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
expect(ws.workspaces.size).toBe(1);
expect(ws.getById(WorkspaceStore.defaultId)).not.toBe(null);
});
it("cannot remove the default workspace", () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
expect(() => ws.removeWorkspaceById(WorkspaceStore.defaultId)).toThrowError("Cannot remove");
});
it("can update workspace description", () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
const workspace = ws.addWorkspace(new Workspace({
id: "foobar",
name: "foobar",
}));
workspace.description = "Foobar description";
ws.updateWorkspace(workspace);
expect(ws.getById("foobar").description).toBe("Foobar description");
});
it("can add workspaces", () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
ws.addWorkspace(new Workspace({
id: "123",
name: "foobar",
}));
const workspace = ws.getById("123");
expect(workspace.name).toBe("foobar");
expect(workspace.enabled).toBe(true);
});
it("cannot set a non-existent workspace to be active", () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
expect(() => ws.setActive("abc")).toThrow("doesn't exist");
});
it("can set a existent workspace to be active", () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
ws.addWorkspace(new Workspace({
id: "abc",
name: "foobar",
}));
expect(() => ws.setActive("abc")).not.toThrowError();
});
it("can remove a workspace", () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
ws.addWorkspace(new Workspace({
id: "123",
name: "foobar",
}));
ws.addWorkspace(new Workspace({
id: "1234",
name: "foobar 1",
}));
ws.removeWorkspaceById("123");
expect(ws.workspaces.size).toBe(2);
});
it("cannot create workspace with existent name", () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
ws.addWorkspace(new Workspace({
id: "someid",
name: "default",
}));
expect(ws.workspacesList.length).toBe(1); // default workspace only
});
it("cannot create workspace with empty name", () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
ws.addWorkspace(new Workspace({
id: "random",
name: "",
}));
expect(ws.workspacesList.length).toBe(1); // default workspace only
});
it("cannot create workspace with ' ' name", () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
ws.addWorkspace(new Workspace({
id: "random",
name: " ",
}));
expect(ws.workspacesList.length).toBe(1); // default workspace only
});
it("trim workspace name", () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
ws.addWorkspace(new Workspace({
id: "random",
name: "default ",
}));
expect(ws.workspacesList.length).toBe(1); // default workspace only
});
});
describe("for a non-empty config", () => {
beforeEach(async () => {
WorkspaceStore.resetInstance();
mockFs({
tmp: {
"lens-workspace-store.json": JSON.stringify({
currentWorkspace: "abc",
workspaces: [{
id: "abc",
name: "test"
}, {
id: "default",
name: "default"
}]
})
}
});
await WorkspaceStore.getInstance<WorkspaceStore>().load();
});
afterEach(() => {
mockFs.restore();
});
it("doesn't revert to default workspace", async () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>();
expect(ws.currentWorkspaceId).toBe("abc");
});
});
});
+9 -6
View File
@@ -19,7 +19,7 @@ export interface BaseStoreParams<T = any> extends ConfOptions<T> {
* Note: T should only contain base JSON serializable types.
*/
export abstract class BaseStore<T = any> extends Singleton {
protected storeConfig: Config<T>;
protected storeConfig?: Config<T>;
protected syncDisposers: Function[] = [];
whenLoaded = when(() => this.isLoaded);
@@ -36,7 +36,7 @@ export abstract class BaseStore<T = any> extends Singleton {
}
get name() {
return path.basename(this.storeConfig.path);
return path.basename(this.path);
}
protected get syncRendererChannel() {
@@ -48,7 +48,7 @@ export abstract class BaseStore<T = any> extends Singleton {
}
get path() {
return this.storeConfig.path;
return this.storeConfig?.path || "";
}
protected async init() {
@@ -82,10 +82,13 @@ export abstract class BaseStore<T = any> extends Singleton {
protected async saveToFile(model: T) {
logger.info(`[STORE]: SAVING ${this.path}`);
// todo: update when fixed https://github.com/sindresorhus/conf/issues/114
Object.entries(model).forEach(([key, value]) => {
this.storeConfig.set(key, value);
});
if (this.storeConfig) {
for (const [key, value] of Object.entries(model)) {
this.storeConfig.set(key, value);
}
}
}
enableSync() {
+56
View File
@@ -0,0 +1,56 @@
import { action, computed, observable, toJS } from "mobx";
import { CatalogCategory, CatalogEntityData } from "./catalog-entity";
export class CatalogCategoryRegistry {
@observable protected categories: CatalogCategory[] = [];
@action add(category: CatalogCategory) {
this.categories.push(category);
}
@action remove(category: CatalogCategory) {
this.categories = this.categories.filter((cat) => cat.apiVersion !== category.apiVersion && cat.kind !== category.kind);
}
@computed get items() {
return toJS(this.categories);
}
getForGroupKind<T extends CatalogCategory>(group: string, kind: string) {
return this.categories.find((c) => c.spec.group === group && c.spec.names.kind === kind) as T;
}
getEntityForData(data: CatalogEntityData) {
const category = this.getCategoryForEntity(data);
if (!category) {
return null;
}
const splitApiVersion = data.apiVersion.split("/");
const version = splitApiVersion[1];
const specVersion = category.spec.versions.find((v) => v.name === version);
if (!specVersion) {
return null;
}
return new specVersion.entityClass(data);
}
getCategoryForEntity<T extends CatalogCategory>(data: CatalogEntityData) {
const splitApiVersion = data.apiVersion.split("/");
const group = splitApiVersion[0];
const category = this.categories.find((category) => {
return category.spec.group === group && category.spec.names.kind === data.kind;
});
if (!category) return null;
return category as T;
}
}
export const catalogCategoryRegistry = new CatalogCategoryRegistry();
+2
View File
@@ -0,0 +1,2 @@
export * from "./kubernetes-cluster";
export * from "./web-link";
@@ -0,0 +1,105 @@
import { EventEmitter } from "events";
import { observable } from "mobx";
import { catalogCategoryRegistry } from "../catalog-category-registry";
import { CatalogCategory, CatalogEntity, CatalogEntityActionContext, CatalogEntityContextMenuContext, CatalogEntityData, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog-entity";
import { clusterDisconnectHandler } from "../cluster-ipc";
import { clusterStore } from "../cluster-store";
import { requestMain } from "../ipc";
export type KubernetesClusterSpec = {
kubeconfigPath: string;
kubeconfigContext: string;
};
export interface KubernetesClusterStatus extends CatalogEntityStatus {
phase: "connected" | "disconnected";
}
export class KubernetesCluster implements CatalogEntity {
public readonly apiVersion = "entity.k8slens.dev/v1alpha1";
public readonly kind = "KubernetesCluster";
@observable public metadata: CatalogEntityMetadata;
@observable public status: KubernetesClusterStatus;
@observable public spec: KubernetesClusterSpec;
constructor(data: CatalogEntityData) {
this.metadata = data.metadata;
this.status = data.status as KubernetesClusterStatus;
this.spec = data.spec as KubernetesClusterSpec;
}
getId() {
return this.metadata.uid;
}
getName() {
return this.metadata.name;
}
async onRun(context: CatalogEntityActionContext) {
context.navigate(`/cluster/${this.metadata.uid}`);
}
async onDetailsOpen() {
//
}
async onContextMenuOpen(context: CatalogEntityContextMenuContext) {
context.menuItems = [
{
icon: "settings",
title: "Settings",
onClick: async () => context.navigate(`/cluster/${this.metadata.uid}/settings`)
},
{
icon: "delete",
title: "Delete",
onClick: async () => clusterStore.removeById(this.metadata.uid),
confirm: {
message: `Remove Kubernetes Cluster "${this.metadata.name} from Lens?`
}
},
];
if (this.status.active) {
context.menuItems.unshift({
icon: "link_off",
title: "Disconnect",
onClick: async () => {
clusterStore.deactivate(this.metadata.uid);
requestMain(clusterDisconnectHandler, this.metadata.uid);
}
});
}
const category = catalogCategoryRegistry.getCategoryForEntity<KubernetesClusterCategory>(this);
if (category) category.emit("contextMenuOpen", this, context);
}
}
export class KubernetesClusterCategory extends EventEmitter implements CatalogCategory {
public readonly apiVersion = "catalog.k8slens.dev/v1alpha1";
public readonly kind = "CatalogCategory";
public metadata = {
name: "Kubernetes Clusters"
};
public spec = {
group: "entity.k8slens.dev",
versions: [
{
name: "v1alpha1",
entityClass: KubernetesCluster
}
],
names: {
kind: "KubernetesCluster"
}
};
getId() {
return `${this.spec.group}/${this.spec.names.kind}`;
}
}
catalogCategoryRegistry.add(new KubernetesClusterCategory());
+71
View File
@@ -0,0 +1,71 @@
import { observable } from "mobx";
import { CatalogCategory, CatalogEntity, CatalogEntityData, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog-entity";
import { catalogCategoryRegistry } from "../catalog-category-registry";
export interface WebLinkStatus extends CatalogEntityStatus {
phase: "valid" | "invalid";
}
export type WebLinkSpec = {
url: string;
};
export class WebLink implements CatalogEntity {
public readonly apiVersion = "entity.k8slens.dev/v1alpha1";
public readonly kind = "KubernetesCluster";
@observable public metadata: CatalogEntityMetadata;
@observable public status: WebLinkStatus;
@observable public spec: WebLinkSpec;
constructor(data: CatalogEntityData) {
this.metadata = data.metadata;
this.status = data.status as WebLinkStatus;
this.spec = data.spec as WebLinkSpec;
}
getId() {
return this.metadata.uid;
}
getName() {
return this.metadata.name;
}
async onRun() {
window.open(this.spec.url, "_blank");
}
async onDetailsOpen() {
//
}
async onContextMenuOpen() {
//
}
}
export class WebLinkCategory implements CatalogCategory {
public readonly apiVersion = "catalog.k8slens.dev/v1alpha1";
public readonly kind = "CatalogCategory";
public metadata = {
name: "Web Links"
};
public spec = {
group: "entity.k8slens.dev",
versions: [
{
name: "v1alpha1",
entityClass: WebLink
}
],
names: {
kind: "WebLink"
}
};
getId() {
return `${this.spec.group}/${this.spec.names.kind}`;
}
}
catalogCategoryRegistry.add(new WebLinkCategory());
+26
View File
@@ -0,0 +1,26 @@
import { action, computed, observable, IObservableArray } from "mobx";
import { CatalogEntity } from "./catalog-entity";
export class CatalogEntityRegistry {
protected sources = observable.map<string, IObservableArray<CatalogEntity>>([], { deep: true });
@action addSource(id: string, source: IObservableArray<CatalogEntity>) {
this.sources.set(id, source);
}
@action removeSource(id: string) {
this.sources.delete(id);
}
@computed get items(): CatalogEntity[] {
return Array.from(this.sources.values()).flat();
}
getItemsForApiKind<T extends CatalogEntity>(apiVersion: string, kind: string): T[] {
const items = this.items.filter((item) => item.apiVersion === apiVersion && item.kind === kind);
return items as T[];
}
}
export const catalogEntityRegistry = new CatalogEntityRegistry();
+75
View File
@@ -0,0 +1,75 @@
export interface CatalogCategoryVersion {
name: string;
entityClass: { new(data: CatalogEntityData): CatalogEntity };
}
export interface CatalogCategory {
apiVersion: string;
kind: string;
metadata: {
name: string;
}
spec: {
group: string;
versions: CatalogCategoryVersion[];
names: {
kind: string;
}
}
getId: () => string;
}
export type CatalogEntityMetadata = {
uid: string;
name: string;
description?: string;
source?: string;
labels: {
[key: string]: string;
}
[key: string]: string | object;
};
export type CatalogEntityStatus = {
phase: string;
reason?: string;
message?: string;
active?: boolean;
};
export interface CatalogEntityActionContext {
navigate: (url: string) => void;
setCommandPaletteContext: (context?: CatalogEntity) => void;
}
export type CatalogEntityContextMenu = {
icon: string;
title: string;
onClick: () => Promise<void>;
confirm?: {
message: string;
}
};
export interface CatalogEntityContextMenuContext {
navigate: (url: string) => void;
menuItems: CatalogEntityContextMenu[];
}
export type CatalogEntityData = {
apiVersion: string;
kind: string;
metadata: CatalogEntityMetadata;
status: CatalogEntityStatus;
spec: {
[key: string]: any;
}
};
export interface CatalogEntity extends CatalogEntityData {
getId: () => string;
getName: () => string;
onRun: (context: CatalogEntityActionContext) => Promise<void>;
onDetailsOpen: (context: CatalogEntityActionContext) => Promise<void>;
onContextMenuOpen: (context: CatalogEntityContextMenuContext) => Promise<void>;
}
+30 -44
View File
@@ -1,4 +1,3 @@
import { workspaceStore } from "./workspace-store";
import path from "path";
import { app, ipcRenderer, remote, webFrame } from "electron";
import { unlink } from "fs-extra";
@@ -12,9 +11,7 @@ import { dumpConfigYaml } from "./kube-helpers";
import { saveToAppFiles } from "./utils/saveToAppFiles";
import { KubeConfig } from "@kubernetes/client-node";
import { handleRequest, requestMain, subscribeToBroadcast, unsubscribeAllFromBroadcast } from "./ipc";
import _ from "lodash";
import move from "array-move";
import type { WorkspaceId } from "./workspace-store";
import { ResourceType } from "../renderer/components/+cluster-settings/components/cluster-metrics-setting";
export interface ClusterIconUpload {
clusterId: string;
@@ -34,7 +31,7 @@ export type ClusterPrometheusMetadata = {
export interface ClusterStoreModel {
activeCluster?: ClusterId; // last opened cluster
clusters?: ClusterModel[]
clusters?: ClusterModel[];
}
export type ClusterId = string;
@@ -46,8 +43,12 @@ export interface ClusterModel {
/** Path to cluster kubeconfig */
kubeConfigPath: string;
/** Workspace id */
workspace?: WorkspaceId;
/**
* Workspace id
*
* @deprecated
*/
workspace?: string;
/** User context in kubeconfig */
contextName?: string;
@@ -70,12 +71,13 @@ export interface ClusterModel {
kubeConfig?: string; // yaml
}
export interface ClusterPreferences extends ClusterPrometheusPreferences{
export interface ClusterPreferences extends ClusterPrometheusPreferences {
terminalCWD?: string;
clusterName?: string;
iconOrder?: number;
icon?: string;
httpsProxy?: string;
hiddenMetrics?: string[];
}
export interface ClusterPrometheusPreferences {
@@ -211,27 +213,24 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
return this.activeCluster === id;
}
@action
setActive(id: ClusterId) {
const clusterId = this.clusters.has(id) ? id : null;
this.activeCluster = clusterId;
workspaceStore.setLastActiveClusterId(clusterId);
isMetricHidden(resource: ResourceType) {
return Boolean(this.active?.preferences.hiddenMetrics?.includes(resource));
}
@action
swapIconOrders(workspace: WorkspaceId, from: number, to: number) {
const clusters = this.getByWorkspaceId(workspace);
setActive(clusterId: ClusterId) {
const cluster = this.clusters.get(clusterId);
if (from < 0 || to < 0 || from >= clusters.length || to >= clusters.length || isNaN(from) || isNaN(to)) {
throw new Error(`invalid from<->to arguments`);
if (!cluster?.enabled) {
clusterId = null;
}
move.mutate(clusters, from, to);
this.activeCluster = clusterId;
}
for (const i in clusters) {
// This resets the iconOrder to the current display order
clusters[i].preferences.iconOrder = +i;
deactivate(id: ClusterId) {
if (this.isActive(id)) {
this.setActive(null);
}
}
@@ -239,15 +238,8 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
return this.clusters.size > 0;
}
getById(id: ClusterId): Cluster {
return this.clusters.get(id);
}
getByWorkspaceId(workspaceId: string): Cluster[] {
const clusters = Array.from(this.clusters.values())
.filter(cluster => cluster.workspace === workspaceId);
return _.sortBy(clusters, cluster => cluster.preferences.iconOrder);
getById(id: ClusterId): Cluster | null {
return this.clusters.get(id) ?? null;
}
@action
@@ -301,13 +293,6 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
}
}
@action
removeByWorkspaceId(workspaceId: string) {
this.getByWorkspaceId(workspaceId).forEach(cluster => {
this.removeById(cluster.id);
});
}
@action
protected fromStore({ activeCluster, clusters = [] }: ClusterStoreModel = {}) {
const currentClusters = this.clusters.toJS();
@@ -323,7 +308,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 +322,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);
}
@@ -354,10 +339,11 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
export const clusterStore = ClusterStore.getInstance<ClusterStore>();
export function getClusterIdFromHost(hostname: string): ClusterId {
const subDomains = hostname.split(":")[0].split(".");
export function getClusterIdFromHost(host: string): ClusterId | undefined {
// e.g host == "%clusterId.localhost:45345"
const subDomains = host.split(":")[0].split(".");
return subDomains.slice(-2)[0]; // e.g host == "%clusterId.localhost:45345"
return subDomains.slice(-2, -1)[0]; // ClusterId or undefined
}
export function getClusterFrameUrl(clusterId: ClusterId) {
@@ -365,7 +351,7 @@ export function getClusterFrameUrl(clusterId: ClusterId) {
}
export function getHostedClusterId() {
return getClusterIdFromHost(location.hostname);
return getClusterIdFromHost(location.host);
}
export function getHostedCluster(): Cluster {
+1 -1
View File
@@ -10,4 +10,4 @@ export class ExecValidationNotFoundError extends Error {
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
}
+67
View File
@@ -0,0 +1,67 @@
import { action, comparer, observable, toJS } from "mobx";
import { BaseStore } from "./base-store";
import migrations from "../migrations/hotbar-store";
export interface HotbarItem {
entity: {
uid: string;
};
params?: {
[key: string]: string;
}
}
export interface Hotbar {
name: string;
items: HotbarItem[];
}
export interface HotbarStoreModel {
hotbars: Hotbar[];
}
export class HotbarStore extends BaseStore<HotbarStoreModel> {
@observable hotbars: Hotbar[] = [];
private constructor() {
super({
configName: "lens-hotbar-store",
accessPropertiesByDotNotation: false, // To make dots safe in cluster context names
syncOptions: {
equals: comparer.structural,
},
migrations,
});
}
@action protected async fromStore(data: Partial<HotbarStoreModel> = {}) {
this.hotbars = data.hotbars || [{
name: "default",
items: []
}];
}
getByName(name: string) {
return this.hotbars.find((hotbar) => hotbar.name === name);
}
add(hotbar: Hotbar) {
this.hotbars.push(hotbar);
}
remove(hotbar: Hotbar) {
this.hotbars = this.hotbars.filter((h) => h !== hotbar);
}
toJSON(): HotbarStoreModel {
const model: HotbarStoreModel = {
hotbars: this.hotbars
};
return toJS(model, {
recurseEverything: true,
});
}
}
export const hotbarStore = HotbarStore.getInstance<HotbarStore>();
@@ -0,0 +1,126 @@
import { EventEmitter } from "events";
import { onCorrect, onceCorrect } from "../type-enforced-ipc";
describe("type enforced ipc tests", () => {
describe("onCorrect tests", () => {
it("should call the handler if the args are valid", () => {
let called = false;
const source = new EventEmitter();
const listener = () => called = true;
const verifier = (args: unknown[]): args is [] => true;
const channel = "foobar";
onCorrect({ source, listener, verifier, channel });
source.emit(channel);
expect(called).toBe(true);
});
it("should not call the handler if the args are not valid", () => {
let called = false;
const source = new EventEmitter();
const listener = () => called = true;
const verifier = (args: unknown[]): args is [] => false;
const channel = "foobar";
onCorrect({ source, listener, verifier, channel });
source.emit(channel);
expect(called).toBe(false);
});
it("should call the handler twice if the args are valid on two emits", () => {
let called = 0;
const source = new EventEmitter();
const listener = () => called += 1;
const verifier = (args: unknown[]): args is [] => true;
const channel = "foobar";
onCorrect({ source, listener, verifier, channel });
source.emit(channel);
source.emit(channel);
expect(called).toBe(2);
});
it("should call the handler twice if the args are [valid, invalid, valid]", () => {
let called = 0;
const source = new EventEmitter();
const listener = () => called += 1;
const results = [true, false, true];
const verifier = (args: unknown[]): args is [] => results.pop();
const channel = "foobar";
onCorrect({ source, listener, verifier, channel });
source.emit(channel);
source.emit(channel);
source.emit(channel);
expect(called).toBe(2);
});
});
describe("onceCorrect tests", () => {
it("should call the handler if the args are valid", () => {
let called = false;
const source = new EventEmitter();
const listener = () => called = true;
const verifier = (args: unknown[]): args is [] => true;
const channel = "foobar";
onceCorrect({ source, listener, verifier, channel });
source.emit(channel);
expect(called).toBe(true);
});
it("should not call the handler if the args are not valid", () => {
let called = false;
const source = new EventEmitter();
const listener = () => called = true;
const verifier = (args: unknown[]): args is [] => false;
const channel = "foobar";
onceCorrect({ source, listener, verifier, channel });
source.emit(channel);
expect(called).toBe(false);
});
it("should call the handler only once even if args are valid multiple times", () => {
let called = 0;
const source = new EventEmitter();
const listener = () => called += 1;
const verifier = (args: unknown[]): args is [] => true;
const channel = "foobar";
onceCorrect({ source, listener, verifier, channel });
source.emit(channel);
source.emit(channel);
expect(called).toBe(1);
});
it("should call the handler on only the first valid set of args", () => {
let called = "";
let verifierCalled = 0;
const source = new EventEmitter();
const listener = (info: any, arg: string) => called = arg;
const verifier = (args: unknown[]): args is [string] => (++verifierCalled) % 3 === 0;
const channel = "foobar";
onceCorrect({ source, listener, verifier, channel });
source.emit(channel, {}, "a");
source.emit(channel, {}, "b");
source.emit(channel, {}, "c");
source.emit(channel, {}, "d");
source.emit(channel, {}, "e");
source.emit(channel, {}, "f");
source.emit(channel, {}, "g");
source.emit(channel, {}, "h");
source.emit(channel, {}, "i");
expect(called).toBe("c");
});
});
});
+11
View File
@@ -0,0 +1,11 @@
/**
* This channel is broadcast on whenever the cluster fails to list namespaces
* during a refresh and no `accessibleNamespaces` have been set.
*/
export const ClusterListNamespaceForbiddenChannel = "cluster:list-namespace-forbidden";
export type ListNamespaceForbiddenArgs = [clusterId: string];
export function isListNamespaceForbiddenArgs(args: unknown[]): args is ListNamespaceForbiddenArgs {
return args.length === 1 && typeof args[0] === "string";
}
+5
View File
@@ -0,0 +1,5 @@
export * from "./ipc";
export * from "./invalid-kubeconfig";
export * from "./update-available.ipc";
export * from "./cluster.ipc";
export * from "./type-enforced-ipc";
@@ -0,0 +1,3 @@
export const InvalidKubeconfigChannel = "invalid-kubeconfig";
export type InvalidKubeConfigArgs = [clusterId: string];
+34 -24
View File
@@ -3,10 +3,13 @@
// https://www.electronjs.org/docs/api/ipc-renderer
import { ipcMain, ipcRenderer, webContents, remote } from "electron";
import logger from "../main/logger";
import { ClusterFrameInfo, clusterFrameMap } from "./cluster-frames";
import { toJS } from "mobx";
import logger from "../../main/logger";
import { ClusterFrameInfo, clusterFrameMap } from "../cluster-frames";
export function handleRequest(channel: string, listener: (...args: any[]) => any) {
const subFramesChannel = "ipc:get-sub-frames";
export function handleRequest(channel: string, listener: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => any) {
ipcMain.handle(channel, listener);
}
@@ -14,38 +17,39 @@ export async function requestMain(channel: string, ...args: any[]) {
return ipcRenderer.invoke(channel, ...args);
}
async function getSubFrames(): Promise<ClusterFrameInfo[]> {
const subFrames: ClusterFrameInfo[] = [];
clusterFrameMap.forEach(frameInfo => {
subFrames.push(frameInfo);
});
return subFrames;
function getSubFrames(): ClusterFrameInfo[] {
return toJS(Array.from(clusterFrameMap.values()), { recurseEverything: true });
}
export function broadcastMessage(channel: string, ...args: any[]) {
export async function broadcastMessage(channel: string, ...args: any[]) {
const views = (webContents || remote?.webContents)?.getAllWebContents();
if (!views) return;
views.forEach(webContent => {
const type = webContent.getType();
logger.silly(`[IPC]: broadcasting "${channel}" to ${type}=${webContent.id}`, { args });
webContent.send(channel, ...args);
getSubFrames().then((frames) => {
frames.map((frameInfo) => {
webContent.sendToFrame([frameInfo.processId, frameInfo.frameId], channel, ...args);
});
}).catch((e) => e);
});
if (ipcRenderer) {
ipcRenderer.send(channel, ...args);
} else {
ipcMain.emit(channel, ...args);
}
for (const view of views) {
const type = view.getType();
logger.silly(`[IPC]: broadcasting "${channel}" to ${type}=${view.id}`, { args });
view.send(channel, ...args);
try {
const subFrames: ClusterFrameInfo[] = ipcRenderer
? await requestMain(subFramesChannel)
: getSubFrames();
for (const frameInfo of subFrames) {
view.sendToFrame([frameInfo.processId, frameInfo.frameId], channel, ...args);
}
} catch (error) {
logger.error("[IPC]: failed to send IPC message", { error: String(error) });
}
}
}
export function subscribeToBroadcast(channel: string, listener: (...args: any[]) => any) {
@@ -73,3 +77,9 @@ export function unsubscribeAllFromBroadcast(channel: string) {
ipcMain.removeAllListeners(channel);
}
}
export function bindBroadcastHandlers() {
handleRequest(subFramesChannel, () => {
return getSubFrames();
});
}
+71
View File
@@ -0,0 +1,71 @@
import { EventEmitter } from "events";
import logger from "../../main/logger";
export type HandlerEvent<EM extends EventEmitter> = Parameters<Parameters<EM["on"]>[1]>[0];
export type ListVerifier<T extends any[]> = (args: unknown[]) => args is T;
export type Rest<T> = T extends [any, ...infer R] ? R : [];
/**
* Adds a listener to `source` that waits for the first IPC message with the correct
* argument data is sent.
* @param channel The channel to be listened on
* @param listener The function for the channel to be called if the args of the correct type
* @param verifier The function to be called to verify that the args are the correct type
*/
export function onceCorrect<
EM extends EventEmitter,
L extends (event: HandlerEvent<EM>, ...args: any[]) => any
>({
source,
channel,
listener,
verifier,
}: {
source: EM,
channel: string | symbol,
listener: L,
verifier: ListVerifier<Rest<Parameters<L>>>,
}): void {
function handler(event: HandlerEvent<EM>, ...args: unknown[]): void {
if (verifier(args)) {
source.removeListener(channel, handler); // remove immediately
(async () => (listener(event, ...args)))() // might return a promise, or throw, or reject
.catch((error: any) => logger.error("[IPC]: channel once handler threw error", { channel, error }));
} else {
logger.error("[IPC]: channel was emitted with invalid data", { channel, args });
}
}
source.on(channel, handler);
}
/**
* Adds a listener to `source` that checks to verify the arguments before calling the handler.
* @param channel The channel to be listened on
* @param listener The function for the channel to be called if the args of the correct type
* @param verifier The function to be called to verify that the args are the correct type
*/
export function onCorrect<
EM extends EventEmitter,
L extends (event: HandlerEvent<EM>, ...args: any[]) => any
>({
source,
channel,
listener,
verifier,
}: {
source: EM,
channel: string | symbol,
listener: L,
verifier: ListVerifier<Rest<Parameters<L>>>,
}): void {
source.on(channel, (event, ...args: unknown[]) => {
if (verifier(args)) {
(async () => (listener(event, ...args)))() // might return a promise, or throw, or reject
.catch(error => logger.error("[IPC]: channel on handler threw error", { channel, error }));
} else {
logger.error("[IPC]: channel was emitted with invalid data", { channel, args });
}
});
}
+45
View File
@@ -0,0 +1,45 @@
import { UpdateInfo } from "electron-updater";
export const UpdateAvailableChannel = "update-available";
export const AutoUpdateLogPrefix = "[UPDATE-CHECKER]";
export type UpdateAvailableFromMain = [backChannel: string, updateInfo: UpdateInfo];
export function areArgsUpdateAvailableFromMain(args: unknown[]): args is UpdateAvailableFromMain {
if (args.length !== 2) {
return false;
}
if (typeof args[0] !== "string") {
return false;
}
if (typeof args[1] !== "object" || args[1] === null) {
// TODO: improve this checking
return false;
}
return true;
}
export type BackchannelArg = {
doUpdate: false;
} | {
doUpdate: true;
now: boolean;
};
export type UpdateAvailableToBackchannel = [updateDecision: BackchannelArg];
export function areArgsUpdateAvailableToBackchannel(args: unknown[]): args is UpdateAvailableToBackchannel {
if (args.length !== 1) {
return false;
}
if (typeof args[0] !== "object" || args[0] === null) {
// TODO: improve this checking
return false;
}
return true;
}
+33 -12
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,28 +157,43 @@ 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.
*/
export function validateKubeConfig (config: KubeConfig) {
* Checks if `config` has valid `Context`, `User`, `Cluster`, and `exec` fields (if present when required)
*/
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 (user.exec) {
if (validateUser && !user) {
throw new Error(`No valid user object provided in kubeconfig for context '${contextName}'`);
}
// 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);
}
}
}
}
+1 -1
View File
@@ -10,4 +10,4 @@ import { PrometheusProviderRegistry } from "../main/prometheus/provider-registry
PrometheusProviderRegistry.registerProvider(provider.id, provider);
});
export const prometheusProviders = PrometheusProviderRegistry.getProviders();
export const prometheusProviders = PrometheusProviderRegistry.getProviders();
+36
View File
@@ -0,0 +1,36 @@
import Url from "url-parse";
export enum RoutingErrorType {
INVALID_PROTOCOL = "invalid-protocol",
INVALID_HOST = "invalid-host",
INVALID_PATHNAME = "invalid-pathname",
NO_HANDLER = "no-handler",
NO_EXTENSION_ID = "no-ext-id",
MISSING_EXTENSION = "missing-ext",
}
export class RoutingError extends Error {
/**
* Will be set if the routing error originated in an extension route table
*/
public extensionName?: string;
constructor(public type: RoutingErrorType, public url: Url) {
super("routing error");
}
toString(): string {
switch (this.type) {
case RoutingErrorType.INVALID_HOST:
return "invalid host";
case RoutingErrorType.INVALID_PROTOCOL:
return "invalid protocol";
case RoutingErrorType.INVALID_PATHNAME:
return "invalid pathname";
case RoutingErrorType.NO_EXTENSION_ID:
return "no extension ID";
case RoutingErrorType.MISSING_EXTENSION:
return "extension not found";
}
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./error";
export * from "./router";
+220
View File
@@ -0,0 +1,220 @@
import { match, matchPath } from "react-router";
import { countBy } from "lodash";
import { Singleton } from "../utils";
import { pathToRegexp } from "path-to-regexp";
import logger from "../../main/logger";
import Url from "url-parse";
import { RoutingError, RoutingErrorType } from "./error";
import { extensionsStore } from "../../extensions/extensions-store";
import { extensionLoader } from "../../extensions/extension-loader";
import { LensExtension } from "../../extensions/lens-extension";
import { RouteHandler, RouteParams } from "../../extensions/registries/protocol-handler-registry";
// IPC channel for protocol actions. Main broadcasts the open-url events to this channel.
export const ProtocolHandlerIpcPrefix = "protocol-handler";
export const ProtocolHandlerInternal = `${ProtocolHandlerIpcPrefix}:internal`;
export const ProtocolHandlerExtension= `${ProtocolHandlerIpcPrefix}:extension`;
/**
* These two names are long and cumbersome by design so as to decrease the chances
* of an extension using the same names.
*
* Though under the current (2021/01/18) implementation, these are never matched
* against in the final matching so their names are less of a concern.
*/
const EXTENSION_PUBLISHER_MATCH = "LENS_INTERNAL_EXTENSION_PUBLISHER_MATCH";
const EXTENSION_NAME_MATCH = "LENS_INTERNAL_EXTENSION_NAME_MATCH";
export abstract class LensProtocolRouter extends Singleton {
// Map between path schemas and the handlers
protected internalRoutes = new Map<string, RouteHandler>();
public static readonly LoggingPrefix = "[PROTOCOL ROUTER]";
protected static readonly ExtensionUrlSchema = `/:${EXTENSION_PUBLISHER_MATCH}(\@[A-Za-z0-9_]+)?/:${EXTENSION_NAME_MATCH}`;
/**
*
* @param url the parsed URL that initiated the `lens://` protocol
*/
protected _routeToInternal(url: Url): void {
this._route(Array.from(this.internalRoutes.entries()), url);
}
/**
* match against all matched URIs, returning either the first exact match or
* the most specific match if none are exact.
* @param routes the array of path schemas, handler pairs to match against
* @param url the url (in its current state)
*/
protected _findMatchingRoute(routes: [string, RouteHandler][], url: Url): null | [match<Record<string, string>>, RouteHandler] {
const matches: [match<Record<string, string>>, RouteHandler][] = [];
for (const [schema, handler] of routes) {
const match = matchPath(url.pathname, { path: schema });
if (!match) {
continue;
}
// prefer an exact match
if (match.isExact) {
return [match, handler];
}
matches.push([match, handler]);
}
// if no exact match pick the one that is the most specific
return matches.sort(([a], [b]) => compareMatches(a, b))[0] ?? null;
}
/**
* find the most specific matching handler and call it
* @param routes the array of (path schemas, handler) paris to match against
* @param url the url (in its current state)
*/
protected _route(routes: [string, RouteHandler][], url: Url, extensionName?: string): void {
const route = this._findMatchingRoute(routes, url);
if (!route) {
const data: Record<string, string> = { url: url.toString() };
if (extensionName) {
data.extensionName = extensionName;
}
return void logger.info(`${LensProtocolRouter.LoggingPrefix}: No handler found`, data);
}
const [match, handler] = route;
const params: RouteParams = {
pathname: match.params,
search: url.query,
};
if (!match.isExact) {
params.tail = url.pathname.slice(match.url.length);
}
handler(params);
}
/**
* Tries to find the matching LensExtension instance
*
* Note: this needs to be async so that `main`'s overloaded version can also be async
* @param url the protocol request URI that was "open"-ed
* @returns either the found name or the instance of `LensExtension`
*/
protected async _findMatchingExtensionByName(url: Url): Promise<LensExtension | string> {
interface ExtensionUrlMatch {
[EXTENSION_PUBLISHER_MATCH]: string;
[EXTENSION_NAME_MATCH]: string;
}
const match = matchPath<ExtensionUrlMatch>(url.pathname, LensProtocolRouter.ExtensionUrlSchema);
if (!match) {
throw new RoutingError(RoutingErrorType.NO_EXTENSION_ID, url);
}
const { [EXTENSION_PUBLISHER_MATCH]: publisher, [EXTENSION_NAME_MATCH]: partialName } = match.params;
const name = [publisher, partialName].filter(Boolean).join("/");
const extension = extensionLoader.userExtensionsByName.get(name);
if (!extension) {
logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched, but not installed`);
return name;
}
if (!extensionsStore.isEnabled(extension.id)) {
logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched, but not enabled`);
return name;
}
logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched`);
return extension;
}
/**
* Find a matching extension by the first one or two path segments of `url` and then try to `_route`
* its correspondingly registered handlers.
*
* If no handlers are found or the extension is not enabled then `_missingHandlers` is called before
* checking if more handlers have been added.
*
* Note: this function modifies its argument, do not reuse
* @param url the protocol request URI that was "open"-ed
*/
protected async _routeToExtension(url: Url): Promise<void> {
const extension = await this._findMatchingExtensionByName(url);
if (typeof extension === "string") {
// failed to find an extension, it returned its name
return;
}
// remove the extension name from the path name so we don't need to match on it anymore
url.set("pathname", url.pathname.slice(extension.name.length + 1));
const handlers = extension
.protocolHandlers
.map<[string, RouteHandler]>(({ pathSchema, handler }) => [pathSchema, handler]);
try {
this._route(handlers, url, extension.name);
} catch (error) {
if (error instanceof RoutingError) {
error.extensionName = extension.name;
}
throw error;
}
}
/**
* Add a handler under the `lens://app` tree of routing.
* @param pathSchema the URI path schema to match against for this handler
* @param handler a function that will be called if a protocol path matches
*/
public addInternalHandler(urlSchema: string, handler: RouteHandler): this {
pathToRegexp(urlSchema); // verify now that the schema is valid
logger.info(`${LensProtocolRouter.LoggingPrefix}: internal registering ${urlSchema}`);
this.internalRoutes.set(urlSchema, handler);
return this;
}
/**
* Remove an internal protocol handler.
* @param pathSchema the path schema that the handler was registered under
*/
public removeInternalHandler(urlSchema: string): void {
this.internalRoutes.delete(urlSchema);
}
}
/**
* a comparison function for `array.sort(...)`. Sort order should be most path
* parts to least path parts.
* @param a the left side to compare
* @param b the right side to compare
*/
function compareMatches<T>(a: match<T>, b: match<T>): number {
if (a.path === "/") {
return 1;
}
if (b.path === "/") {
return -1;
}
return countBy(b.path)["/"] - countBy(a.path)["/"];
}
+28 -26
View File
@@ -1,42 +1,44 @@
import { getHostedCluster } from "./cluster-store";
export type KubeResource =
"namespaces" | "nodes" | "events" | "resourcequotas" | "services" |
"namespaces" | "nodes" | "events" | "resourcequotas" | "services" | "limitranges" |
"secrets" | "configmaps" | "ingresses" | "networkpolicies" | "persistentvolumeclaims" | "persistentvolumes" | "storageclasses" |
"pods" | "daemonsets" | "deployments" | "statefulsets" | "replicasets" | "jobs" | "cronjobs" |
"endpoints" | "customresourcedefinitions" | "horizontalpodautoscalers" | "podsecuritypolicies" | "poddisruptionbudgets";
export interface KubeApiResource {
resource: KubeResource; // valid resource name
kind: string; // resource type (e.g. "Namespace")
apiName: KubeResource; // valid api resource name (e.g. "namespaces")
group?: string; // api-group
}
// TODO: auto-populate all resources dynamically (see: kubectl api-resources -o=wide -v=7)
export const apiResources: KubeApiResource[] = [
{ resource: "configmaps" },
{ resource: "cronjobs", group: "batch" },
{ resource: "customresourcedefinitions", group: "apiextensions.k8s.io" },
{ resource: "daemonsets", group: "apps" },
{ resource: "deployments", group: "apps" },
{ resource: "endpoints" },
{ resource: "events" },
{ resource: "horizontalpodautoscalers" },
{ resource: "ingresses", group: "networking.k8s.io" },
{ resource: "jobs", group: "batch" },
{ resource: "namespaces" },
{ resource: "networkpolicies", group: "networking.k8s.io" },
{ resource: "nodes" },
{ resource: "persistentvolumes" },
{ resource: "persistentvolumeclaims" },
{ resource: "pods" },
{ resource: "poddisruptionbudgets" },
{ resource: "podsecuritypolicies" },
{ resource: "resourcequotas" },
{ resource: "replicasets", group: "apps" },
{ resource: "secrets" },
{ resource: "services" },
{ resource: "statefulsets", group: "apps" },
{ resource: "storageclasses", group: "storage.k8s.io" },
{ kind: "ConfigMap", apiName: "configmaps" },
{ kind: "CronJob", apiName: "cronjobs", group: "batch" },
{ kind: "CustomResourceDefinition", apiName: "customresourcedefinitions", group: "apiextensions.k8s.io" },
{ kind: "DaemonSet", apiName: "daemonsets", group: "apps" },
{ kind: "Deployment", apiName: "deployments", group: "apps" },
{ kind: "Endpoint", apiName: "endpoints" },
{ kind: "Event", apiName: "events" },
{ kind: "HorizontalPodAutoscaler", apiName: "horizontalpodautoscalers" },
{ kind: "Ingress", apiName: "ingresses", group: "networking.k8s.io" },
{ kind: "Job", apiName: "jobs", group: "batch" },
{ kind: "Namespace", apiName: "namespaces" },
{ kind: "LimitRange", apiName: "limitranges" },
{ kind: "NetworkPolicy", apiName: "networkpolicies", group: "networking.k8s.io" },
{ kind: "Node", apiName: "nodes" },
{ kind: "PersistentVolume", apiName: "persistentvolumes" },
{ kind: "PersistentVolumeClaim", apiName: "persistentvolumeclaims" },
{ kind: "Pod", apiName: "pods" },
{ kind: "PodDisruptionBudget", apiName: "poddisruptionbudgets", group: "policy" },
{ kind: "PodSecurityPolicy", apiName: "podsecuritypolicies" },
{ kind: "ResourceQuota", apiName: "resourcequotas" },
{ kind: "ReplicaSet", apiName: "replicasets", group: "apps" },
{ kind: "Secret", apiName: "secrets" },
{ kind: "Service", apiName: "services" },
{ kind: "StatefulSet", apiName: "statefulsets", group: "apps" },
{ kind: "StorageClass", apiName: "storageclasses", group: "storage.k8s.io" },
];
export function isAllowedResource(resources: KubeResource | KubeResource[]) {
+67 -47
View File
@@ -1,10 +1,42 @@
import { action, computed, observable } from "mobx";
import { action, computed, observable,reaction } from "mobx";
import { dockStore } from "../renderer/components/dock/dock.store";
import { autobind } from "../renderer/utils";
export class SearchStore {
@observable searchQuery = ""; // Text in the search input
@observable occurrences: number[] = []; // Array with line numbers, eg [0, 0, 10, 21, 21, 40...]
@observable activeOverlayIndex = -1; // Index withing the occurences array. Showing where is activeOverlay currently located
/**
* An utility methods escaping user string to safely pass it into new Regex(variable)
* @param value Unescaped string
*/
public static escapeRegex(value?: string): string {
return value ? value.replace(/[\-\[\]{}()*+?.,\\\^$|#\s]/g, "\\$&") : "";
}
/**
* Text in the search input
*
* @observable
*/
@observable searchQuery = "";
/**
* Array with line numbers, eg [0, 0, 10, 21, 21, 40...]
*
* @observable
*/
@observable occurrences: number[] = [];
/**
* Index within the occurrences array. Showing where is activeOverlay currently located
*
* @observable
*/
@observable activeOverlayIndex = -1;
constructor() {
reaction(() => dockStore.selectedTabId, () => {
searchStore.reset();
});
}
/**
* Sets default activeOverlayIndex
@@ -12,49 +44,45 @@ export class SearchStore {
* @param query Search query from input
*/
@action
onSearch(text: string[], query = this.searchQuery) {
public onSearch(text?: string[] | null, query = this.searchQuery): void {
this.searchQuery = query;
if (!query) {
this.reset();
return this.reset();
}
this.occurrences = this.findOccurrences(text ?? [], query);
if (!this.occurrences.length) {
return;
}
this.occurrences = this.findOccurences(text, query);
if (!this.occurrences.length) return;
// If new highlighted keyword in exact same place as previous one, then no changing in active overlay
if (this.occurrences[this.activeOverlayIndex] !== undefined) return;
this.activeOverlayIndex = this.getNextOverlay(true);
if (this.occurrences[this.activeOverlayIndex] === undefined) {
this.activeOverlayIndex = this.getNextOverlay(true);
}
}
/**
* Does searching within text array, create a list of search keyword occurences.
* Each keyword "occurency" is saved as index of the the line where keyword founded
* @param text An array of any textual data (logs, for example)
* Does searching within text array, create a list of search keyword occurrences.
* Each keyword "occurrence" is saved as index of the line where keyword was found
* @param lines An array of any textual data (logs, for example)
* @param query Search query from input
* @returns {Array} Array of line indexes [0, 0, 14, 17, 17, 17, 20...]
* @returns Array of line indexes [0, 0, 14, 17, 17, 17, 20...]
*/
findOccurences(text: string[], query: string) {
if (!text) return [];
const occurences: number[] = [];
private findOccurrences(lines: string[], query?: string): number[] {
const regex = new RegExp(SearchStore.escapeRegex(query), "gi");
text.forEach((line, index) => {
const regex = new RegExp(this.escapeRegex(query), "gi");
const matches = [...line.matchAll(regex)];
matches.forEach(() => occurences.push(index));
});
return occurences;
return lines
.flatMap((line, index) => Array.from(line.matchAll(regex), () => index));
}
/**
* Getting next overlay index within the occurences array
* Getting next overlay index within the occurrences array
* @param loopOver Allows to jump from last element to first
* @returns {number} next overlay index
* @returns next overlay index
*/
getNextOverlay(loopOver = false) {
private getNextOverlay(loopOver = false): number {
const next = this.activeOverlayIndex + 1;
if (next > this.occurrences.length - 1) {
@@ -65,11 +93,11 @@ export class SearchStore {
}
/**
* Getting previous overlay index within the occurences array of occurences
* Getting previous overlay index within the occurrences array of occurrences
* @param loopOver Allows to jump from first element to last one
* @returns {number} prev overlay index
* @returns previous overlay index
*/
getPrevOverlay(loopOver = false) {
private getPrevOverlay(loopOver = false): number {
const prev = this.activeOverlayIndex - 1;
if (prev < 0) {
@@ -80,18 +108,18 @@ export class SearchStore {
}
@autobind()
setNextOverlayActive() {
public setNextOverlayActive(): void {
this.activeOverlayIndex = this.getNextOverlay(true);
}
@autobind()
setPrevOverlayActive() {
public setPrevOverlayActive(): void {
this.activeOverlayIndex = this.getPrevOverlay(true);
}
/**
* Gets line index of where active overlay is located
* @returns {number} A line index within the text/logs array
* @returns A line index within the text/logs array
*/
@computed get activeOverlayLine(): number {
return this.occurrences[this.activeOverlayIndex];
@@ -108,29 +136,21 @@ export class SearchStore {
/**
* Checks if overlay is active (to highlight it with orange background usually)
* @param line Index of the line where overlay is located
* @param occurence Number of the overlay within one line
* @param occurrence Number of the overlay within one line
*/
@autobind()
isActiveOverlay(line: number, occurence: number) {
public isActiveOverlay(line: number, occurrence: number): boolean {
const firstLineIndex = this.occurrences.findIndex(item => item === line);
return firstLineIndex + occurence === this.activeOverlayIndex;
}
/**
* An utility methods escaping user string to safely pass it into new Regex(variable)
* @param value Unescaped string
*/
escapeRegex(value: string) {
return value.replace( /[\-\[\]{}()*+?.,\\\^$|#\s]/g, "\\$&" );
return firstLineIndex + occurrence === this.activeOverlayIndex;
}
@action
reset() {
private reset(): void {
this.searchQuery = "";
this.activeOverlayIndex = -1;
this.occurrences = [];
}
}
export const searchStore = new SearchStore;
export const searchStore = new SearchStore;
+29 -2
View File
@@ -10,6 +10,7 @@ import { kubeConfigDefaultPath, loadConfig } from "./kube-helpers";
import { appEventBus } from "./event-bus";
import logger from "../main/logger";
import path from "path";
import { fileNameMigration } from "../migrations/user-store";
export interface UserStoreModel {
kubeConfigPath: string;
@@ -20,6 +21,7 @@ export interface UserStoreModel {
export interface UserPreferences {
httpsProxy?: string;
shell?: string;
colorTheme?: string;
allowUntrustedCAs?: boolean;
allowTelemetry?: boolean;
@@ -28,6 +30,7 @@ export interface UserPreferences {
downloadBinariesPath?: string;
kubectlBinariesPath?: string;
openAtLogin?: boolean;
hiddenTableColumns?: Record<string, string[]>;
}
export class UserStore extends BaseStore<UserStoreModel> {
@@ -35,7 +38,7 @@ export class UserStore extends BaseStore<UserStoreModel> {
private constructor() {
super({
// configName: "lens-user-store", // todo: migrate from default "config.json"
configName: "lens-user-store",
migrations,
});
@@ -54,6 +57,7 @@ export class UserStore extends BaseStore<UserStoreModel> {
downloadMirror: "default",
downloadKubectlBinaries: true, // Download kubectl binaries matching cluster version
openAtLogin: false,
hiddenTableColumns: {},
};
protected async handleOnLoad() {
@@ -71,17 +75,40 @@ export class UserStore extends BaseStore<UserStoreModel> {
// open at system start-up
reaction(() => this.preferences.openAtLogin, openAtLogin => {
app.setLoginItemSettings({ openAtLogin });
app.setLoginItemSettings({
openAtLogin,
openAsHidden: true,
args: ["--hidden"]
});
}, {
fireImmediately: true,
});
}
}
async load(): Promise<void> {
/**
* This has to be here before the call to `new Config` in `super.load()`
* as we have to make sure that file is in the expected place for that call
*/
await fileNameMigration();
return super.load();
}
get isNewVersion() {
return semver.gt(getAppVersion(), this.lastSeenAppVersion);
}
@action
setHiddenTableColumns(tableId: string, names: Set<string> | string[]) {
this.preferences.hiddenTableColumns[tableId] = Array.from(names);
}
getHiddenTableColumns(tableId: string): Set<string> {
return new Set(this.preferences.hiddenTableColumns[tableId]);
}
@action
resetKubeConfigPath() {
this.kubeConfigPath = kubeConfigDefaultPath;
@@ -28,4 +28,4 @@ describe("split array on element tests", () => {
test("ten elements, in end array", () => {
expect(splitArray([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 9)).toStrictEqual([[0, 1, 2, 3, 4, 5, 6, 7, 8], [], true]);
});
});
});
+11
View File
@@ -1,3 +1,4 @@
import requestPromise from "request-promise-native";
import packageInfo from "../../../package.json";
export function getAppVersion(): string {
@@ -11,3 +12,13 @@ export function getBundledKubectlVersion(): string {
export function getBundledExtensions(): string[] {
return packageInfo.lens?.extensions || [];
}
export async function getAppVersionFromProxyServer(proxyPort: number): Promise<string> {
const response = await requestPromise({
method: "GET",
uri: `http://localhost:${proxyPort}/version`,
resolveWithFullResponse: true
});
return JSON.parse(response.body).version;
}
+8 -2
View File
@@ -3,14 +3,20 @@ import { compile } from "path-to-regexp";
export interface IURLParams<P extends object = {}, Q extends object = {}> {
params?: P;
query?: Q;
fragment?: string;
}
export function buildURL<P extends object = {}, Q extends object = {}>(path: string | any) {
const pathBuilder = compile(String(path));
return function ({ params, query }: IURLParams<P, Q> = {}) {
return function ({ params, query, fragment }: IURLParams<P, Q> = {}): string {
const queryParams = query ? new URLSearchParams(Object.entries(query)).toString() : "";
const parts = [
pathBuilder(params),
queryParams && `?${queryParams}`,
fragment && `#${fragment}`,
];
return pathBuilder(params) + (queryParams ? `?${queryParams}` : "");
return parts.filter(Boolean).join("");
};
}
+19
View File
@@ -0,0 +1,19 @@
import { AbortController } from "abort-controller";
/**
* Return a promise that will be resolved after at least `timeout` ms have
* passed. If `failFast` is provided then the promise is also resolved if it has
* been aborted.
* @param timeout The number of milliseconds before resolving
* @param failFast An abort controller instance to cause the delay to short-circuit
*/
export function delay(timeout = 1000, failFast?: AbortController): Promise<void> {
return new Promise(resolve => {
const timeoutId = setTimeout(resolve, timeout);
failFast?.signal.addEventListener("abort", () => {
clearTimeout(timeoutId);
resolve();
});
});
}
+2
View File
@@ -7,6 +7,7 @@ export * from "./autobind";
export * from "./base64";
export * from "./camelCase";
export * from "./cloneJson";
export * from "./delay";
export * from "./debouncePromise";
export * from "./defineGlobal";
export * from "./getRandId";
@@ -17,3 +18,4 @@ export * from "./openExternal";
export * from "./downloadFile";
export * from "./escapeRegExp";
export * from "./tar";
export * from "./type-narrowing";
+1 -1
View File
@@ -26,4 +26,4 @@ class Singleton {
}
export { Singleton };
export default Singleton;
export default Singleton;
+13
View File
@@ -0,0 +1,13 @@
/**
* Narrows `val` to include the property `key` (if true is returned)
* @param val The object to be tested
* @param key The key to test if it is present on the object
*/
export function hasOwnProperty<V extends object, K extends PropertyKey>(val: V, key: K): val is (V & { [key in K]: unknown }) {
// this call syntax is for when `val` was created by `Object.create(null)`
return Object.prototype.hasOwnProperty.call(val, key);
}
export function hasOwnProperties<V extends object, K extends PropertyKey>(val: V, ...keys: K[]): val is (V & { [key in K]: unknown}) {
return keys.every(key => hasOwnProperty(val, key));
}
-319
View File
@@ -1,319 +0,0 @@
import { ipcRenderer } from "electron";
import { action, computed, observable, toJS, reaction } from "mobx";
import { BaseStore } from "./base-store";
import { clusterStore } from "./cluster-store";
import { appEventBus } from "./event-bus";
import { broadcastMessage, handleRequest, requestMain } from "../common/ipc";
import logger from "../main/logger";
import type { ClusterId } from "./cluster-store";
export type WorkspaceId = string;
export interface WorkspaceStoreModel {
workspaces: WorkspaceModel[];
currentWorkspace?: WorkspaceId;
}
export interface WorkspaceModel {
id: WorkspaceId;
name: string;
description?: string;
ownerRef?: string;
lastActiveClusterId?: ClusterId;
}
export interface WorkspaceState {
enabled: boolean;
}
/**
* Workspace
*
* @beta
*/
export class Workspace implements WorkspaceModel, WorkspaceState {
/**
* Unique id for workspace
*
* @observable
*/
@observable id: WorkspaceId;
/**
* Workspace name
*
* @observable
*/
@observable name: string;
/**
* Workspace description
*
* @observable
*/
@observable description?: string;
/**
* Workspace owner reference
*
* If extension sets ownerRef then it needs to explicitly mark workspace as enabled onActivate (or when workspace is saved)
*
* @observable
*/
@observable ownerRef?: string;
/**
* Is workspace enabled
*
* Workspaces that don't have ownerRef will be enabled by default. Workspaces with ownerRef need to explicitly enable a workspace.
*
* @observable
*/
@observable enabled: boolean;
/**
* Last active cluster id
*
* @observable
*/
@observable lastActiveClusterId?: ClusterId;
constructor(data: WorkspaceModel) {
Object.assign(this, data);
if (!ipcRenderer) {
reaction(() => this.getState(), () => {
this.pushState();
});
}
}
/**
* Is workspace managed by an extension
*/
get isManaged(): boolean {
return !!this.ownerRef;
}
/**
* Get workspace state
*
*/
getState(): WorkspaceState {
return toJS({
enabled: this.enabled
});
}
/**
* Push state
*
* @interal
* @param state workspace state
*/
pushState(state = this.getState()) {
logger.silly("[WORKSPACE] pushing state", {...state, id: this.id});
broadcastMessage("workspace:state", this.id, toJS(state));
}
/**
*
* @param state workspace state
*/
@action setState(state: WorkspaceState) {
Object.assign(this, state);
}
toJSON(): WorkspaceModel {
return toJS({
id: this.id,
name: this.name,
description: this.description,
ownerRef: this.ownerRef,
lastActiveClusterId: this.lastActiveClusterId
});
}
}
export class WorkspaceStore extends BaseStore<WorkspaceStoreModel> {
static readonly defaultId: WorkspaceId = "default";
private static stateRequestChannel = "workspace:states";
private constructor() {
super({
configName: "lens-workspace-store",
});
}
async load() {
await super.load();
type workspaceStateSync = {
id: string;
state: WorkspaceState;
};
if (ipcRenderer) {
logger.info("[WORKSPACE-STORE] requesting initial state sync");
const workspaceStates: workspaceStateSync[] = await requestMain(WorkspaceStore.stateRequestChannel);
workspaceStates.forEach((workspaceState) => {
const workspace = this.getById(workspaceState.id);
if (workspace) {
workspace.setState(workspaceState.state);
}
});
} else {
handleRequest(WorkspaceStore.stateRequestChannel, (): workspaceStateSync[] => {
const states: workspaceStateSync[] = [];
this.workspacesList.forEach((workspace) => {
states.push({
state: workspace.getState(),
id: workspace.id
});
});
return states;
});
}
}
registerIpcListener() {
logger.info("[WORKSPACE-STORE] starting to listen state events");
ipcRenderer.on("workspace:state", (event, workspaceId: string, state: WorkspaceState) => {
this.getById(workspaceId)?.setState(state);
});
}
unregisterIpcListener() {
super.unregisterIpcListener();
ipcRenderer.removeAllListeners("workspace:state");
}
@observable currentWorkspaceId = WorkspaceStore.defaultId;
@observable workspaces = observable.map<WorkspaceId, Workspace>({
[WorkspaceStore.defaultId]: new Workspace({
id: WorkspaceStore.defaultId,
name: "default"
})
});
@computed get currentWorkspace(): Workspace {
return this.getById(this.currentWorkspaceId);
}
@computed get workspacesList() {
return Array.from(this.workspaces.values());
}
@computed get enabledWorkspacesList() {
return this.workspacesList.filter((w) => w.enabled);
}
pushState() {
this.workspaces.forEach((w) => {
w.pushState();
});
}
isDefault(id: WorkspaceId) {
return id === WorkspaceStore.defaultId;
}
getById(id: WorkspaceId): Workspace {
return this.workspaces.get(id);
}
getByName(name: string): Workspace {
return this.workspacesList.find(workspace => workspace.name === name);
}
@action
setActive(id = WorkspaceStore.defaultId) {
if (id === this.currentWorkspaceId) return;
if (!this.getById(id)) {
throw new Error(`workspace ${id} doesn't exist`);
}
this.currentWorkspaceId = id;
}
@action
addWorkspace(workspace: Workspace) {
const { id, name } = workspace;
if (!name.trim() || this.getByName(name.trim())) {
return;
}
this.workspaces.set(id, workspace);
if (!workspace.isManaged) {
workspace.enabled = true;
}
appEventBus.emit({name: "workspace", action: "add"});
return workspace;
}
@action
updateWorkspace(workspace: Workspace) {
this.workspaces.set(workspace.id, workspace);
appEventBus.emit({name: "workspace", action: "update"});
}
@action
removeWorkspace(workspace: Workspace) {
this.removeWorkspaceById(workspace.id);
}
@action
removeWorkspaceById(id: WorkspaceId) {
const workspace = this.getById(id);
if (!workspace) return;
if (this.isDefault(id)) {
throw new Error("Cannot remove default workspace");
}
if (this.currentWorkspaceId === id) {
this.currentWorkspaceId = WorkspaceStore.defaultId; // reset to default
}
this.workspaces.delete(id);
appEventBus.emit({name: "workspace", action: "remove"});
clusterStore.removeByWorkspaceId(id);
}
@action
setLastActiveClusterId(clusterId?: ClusterId, workspaceId = this.currentWorkspaceId) {
this.getById(workspaceId).lastActiveClusterId = clusterId;
}
@action
protected fromStore({ currentWorkspace, workspaces = [] }: WorkspaceStoreModel) {
if (currentWorkspace) {
this.currentWorkspaceId = currentWorkspace;
}
if (workspaces.length) {
this.workspaces.clear();
workspaces.forEach(ws => {
const workspace = new Workspace(ws);
if (!workspace.isManaged) {
workspace.enabled = true;
}
this.workspaces.set(workspace.id, workspace);
});
}
}
toJSON(): WorkspaceStoreModel {
return toJS({
currentWorkspace: this.currentWorkspaceId,
workspaces: this.workspacesList.map((w) => w.toJSON()),
}, {
recurseEverything: true
});
}
}
export const workspaceStore = WorkspaceStore.getInstance<WorkspaceStore>();
+15 -8
View File
@@ -3,11 +3,12 @@ import path from "path";
import hb from "handlebars";
import { observable } from "mobx";
import { ResourceApplier } from "../main/resource-applier";
import { Cluster } from "../main/cluster";
import { KubernetesCluster } from "./core-api/stores";
import logger from "../main/logger";
import { app } from "electron";
import { requestMain } from "../common/ipc";
import { clusterKubectlApplyAllHandler } from "../common/cluster-ipc";
import { clusterStore } from "../common/cluster-store";
export interface ClusterFeatureStatus {
/** feature's current version, as set by the implementation */
@@ -44,7 +45,7 @@ export abstract class ClusterFeature {
*
* @param cluster the cluster that the feature is to be installed on
*/
abstract async install(cluster: Cluster): Promise<void>;
abstract install(cluster: KubernetesCluster): Promise<void>;
/**
* to be implemented in the derived class, this method is typically called by Lens when a user has indicated that this feature is to be upgraded. The implementation
@@ -52,7 +53,7 @@ export abstract class ClusterFeature {
*
* @param cluster the cluster that the feature is to be upgraded on
*/
abstract async upgrade(cluster: Cluster): Promise<void>;
abstract upgrade(cluster: KubernetesCluster): Promise<void>;
/**
* to be implemented in the derived class, this method is typically called by Lens when a user has indicated that this feature is to be uninstalled. The implementation
@@ -60,7 +61,7 @@ export abstract class ClusterFeature {
*
* @param cluster the cluster that the feature is to be uninstalled from
*/
abstract async uninstall(cluster: Cluster): Promise<void>;
abstract uninstall(cluster: KubernetesCluster): Promise<void>;
/**
* to be implemented in the derived class, this method is called periodically by Lens to determine details about the feature's current status. The implementation
@@ -72,7 +73,7 @@ export abstract class ClusterFeature {
*
* @return a promise, resolved with the updated ClusterFeatureStatus
*/
abstract async updateStatus(cluster: Cluster): Promise<ClusterFeatureStatus>;
abstract updateStatus(cluster: KubernetesCluster): Promise<ClusterFeatureStatus>;
/**
* this is a helper method that conveniently applies kubernetes resources to the cluster.
@@ -82,9 +83,15 @@ export abstract class ClusterFeature {
* files are templated, the template parameters are filled using the templateContext field (See renderTemplate() method). Finally the resources are applied to the
* cluster. As a string[] type resourceSpec is treated as an array of fully formed (not templated) kubernetes resources that are applied to the cluster
*/
protected async applyResources(cluster: Cluster, resourceSpec: string | string[]) {
protected async applyResources(cluster: KubernetesCluster, resourceSpec: string | string[]) {
let resources: string[];
const clusterModel = clusterStore.getById(cluster.metadata.uid);
if (!clusterModel) {
throw new Error(`cluster not found`);
}
if ( typeof resourceSpec === "string" ) {
resources = this.renderTemplates(resourceSpec);
} else {
@@ -92,9 +99,9 @@ export abstract class ClusterFeature {
}
if (app) {
await new ResourceApplier(cluster).kubectlApplyAll(resources);
await new ResourceApplier(clusterModel).kubectlApplyAll(resources);
} else {
await requestMain(clusterKubectlApplyAllHandler, cluster.id, resources);
await requestMain(clusterKubectlApplyAllHandler, cluster.metadata.uid, resources);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { computed } from "mobx";
import { CatalogEntity } from "../../common/catalog-entity";
import { catalogEntityRegistry as registry } from "../../common/catalog-entity-registry";
export class CatalogEntityRegistry {
@computed getItemsForApiKind<T extends CatalogEntity>(apiVersion: string, kind: string): T[] {
return registry.getItemsForApiKind<T>(apiVersion, kind);
}
}
export const catalogEntities = new CatalogEntityRegistry();
+3 -7
View File
@@ -1,8 +1,4 @@
export { ExtensionStore } from "../extension-store";
export { clusterStore, Cluster, ClusterStore } from "../stores/cluster-store";
export type { ClusterModel, ClusterId } from "../stores/cluster-store";
export { workspaceStore, Workspace, WorkspaceStore } from "../stores/workspace-store";
export type { WorkspaceId, WorkspaceModel } from "../stores/workspace-store";
export { KubernetesCluster, KubernetesClusterCategory } from "../../common/catalog-entities/kubernetes-cluster";
export { catalogCategoryRegistry as catalogCategories } from "../../common/catalog-category-registry";
export { catalogEntities } from "./catalog";
+28 -4
View File
@@ -14,7 +14,7 @@ import type { LensRendererExtension } from "./lens-renderer-extension";
import * as registries from "./registries";
import fs from "fs";
// lazy load so that we get correct userData
export function extensionPackagesRoot() {
return path.join((app || remote.app).getPath("userData"));
}
@@ -52,6 +52,30 @@ export class ExtensionLoader {
return extensions;
}
@computed get userExtensionsByName(): Map<string, LensExtension> {
const extensions = new Map();
for (const [, val] of this.instances.toJS()) {
if (val.isBundled) {
continue;
}
extensions.set(val.manifest.name, val);
}
return extensions;
}
getExtensionByName(name: string): LensExtension | null {
for (const [, val] of this.instances) {
if (val.name === name) {
return val;
}
}
return null;
}
// Transform userExtensions to a state object for storing into ExtensionsStore
@computed get storeState() {
return Object.fromEntries(
@@ -102,7 +126,6 @@ export class ExtensionLoader {
} catch (error) {
logger.error(`${logModule}: deactivation extension error`, { lensExtensionId, error });
}
}
removeExtension(lensExtensionId: LensExtensionId) {
@@ -190,8 +213,8 @@ export class ExtensionLoader {
registries.globalPageRegistry.add(extension.globalPages, extension),
registries.globalPageMenuRegistry.add(extension.globalPageMenus, extension),
registries.appPreferenceRegistry.add(extension.appPreferences),
registries.clusterFeatureRegistry.add(extension.clusterFeatures),
registries.statusBarRegistry.add(extension.statusBarItems),
registries.commandRegistry.add(extension.commands),
];
this.events.on("remove", (removedExtension: LensRendererExtension) => {
@@ -220,7 +243,8 @@ export class ExtensionLoader {
registries.clusterPageMenuRegistry.add(extension.clusterPageMenus, extension),
registries.kubeObjectMenuRegistry.add(extension.kubeObjectMenuItems),
registries.kubeObjectDetailRegistry.add(extension.kubeObjectDetailItems),
registries.kubeObjectStatusRegistry.add(extension.kubeObjectStatusTexts)
registries.kubeObjectStatusRegistry.add(extension.kubeObjectStatusTexts),
registries.commandRegistry.add(extension.commands),
];
this.events.on("remove", (removedExtension: LensRendererExtension) => {
+1 -1
View File
@@ -27,7 +27,7 @@ export class ExtensionsStore extends BaseStore<LensExtensionsStoreModel> {
protected state = observable.map<LensExtensionId, LensExtensionState>();
isEnabled(extId: LensExtensionId) {
isEnabled(extId: LensExtensionId): boolean {
const state = this.state.get(extId);
// By default false, so that copied extensions are disabled by default.
+1
View File
@@ -0,0 +1 @@
export * from "../../common/catalog-entity";
+2 -1
View File
@@ -1 +1,2 @@
export * from "./registrations";
export * from "./registrations";
export * from "./catalog";
+2 -2
View File
@@ -1,8 +1,8 @@
export type { AppPreferenceRegistration, AppPreferenceComponents } from "../registries/app-preference-registry";
export type { ClusterFeatureRegistration, ClusterFeatureComponents } from "../registries/cluster-feature-registry";
export type { KubeObjectDetailRegistration, KubeObjectDetailComponents } from "../registries/kube-object-detail-registry";
export type { KubeObjectMenuRegistration, KubeObjectMenuComponents } from "../registries/kube-object-menu-registry";
export type { KubeObjectStatusRegistration } from "../registries/kube-object-status-registry";
export type { PageRegistration, RegisteredPage, PageParams, PageComponentProps, PageComponents, PageTarget } from "../registries/page-registry";
export type { PageMenuRegistration, ClusterPageMenuRegistration, PageMenuComponents } from "../registries/page-menu-registry";
export type { StatusBarRegistration } from "../registries/status-bar-registry";
export type { StatusBarRegistration } from "../registries/status-bar-registry";
export type { ProtocolHandlerRegistration, RouteParams as ProtocolRouteParams, RouteHandler as ProtocolRouteHandler } from "../registries/protocol-handler-registry";
+3
View File
@@ -2,6 +2,7 @@ import type { InstalledExtension } from "./extension-discovery";
import { action, observable, reaction } from "mobx";
import { filesystemProvisionerStore } from "../main/extension-filesystem";
import logger from "../main/logger";
import { ProtocolHandlerRegistration } from "./registries/protocol-handler-registry";
export type LensExtensionId = string; // path to manifest (package.json)
export type LensExtensionConstructor = new (...args: ConstructorParameters<typeof LensExtension>) => LensExtension;
@@ -21,6 +22,8 @@ export class LensExtension {
readonly manifestPath: string;
readonly isBundled: boolean;
protocolHandlers: ProtocolHandlerRegistration[] = [];
@observable private isEnabled = false;
constructor({ id, manifest, manifestPath, isBundled }: InstalledExtension) {
+11
View File
@@ -2,6 +2,9 @@ import type { MenuRegistration } from "./registries/menu-registry";
import { LensExtension } from "./lens-extension";
import { WindowManager } from "../main/window-manager";
import { getExtensionPageUrl } from "./registries/page-registry";
import { catalogEntityRegistry } from "../common/catalog-entity-registry";
import { CatalogEntity } from "../common/catalog-entity";
import { IObservableArray } from "mobx";
export class LensMainExtension extends LensExtension {
appMenus: MenuRegistration[] = [];
@@ -16,4 +19,12 @@ export class LensMainExtension extends LensExtension {
await windowManager.navigate(pageUrl, frameId);
}
addCatalogSource(id: string, source: IObservableArray<CatalogEntity>) {
catalogEntityRegistry.addSource(`${this.name}:${id}`, source);
}
removeCatalogSource(id: string) {
catalogEntityRegistry.removeSource(`${this.name}:${id}`);
}
}
+4 -4
View File
@@ -1,7 +1,8 @@
import type { AppPreferenceRegistration, ClusterFeatureRegistration, ClusterPageMenuRegistration, KubeObjectDetailRegistration, KubeObjectMenuRegistration, KubeObjectStatusRegistration, PageMenuRegistration, PageRegistration, StatusBarRegistration, } from "./registries";
import type { AppPreferenceRegistration, ClusterPageMenuRegistration, KubeObjectDetailRegistration, KubeObjectMenuRegistration, KubeObjectStatusRegistration, PageMenuRegistration, PageRegistration, StatusBarRegistration, } from "./registries";
import type { Cluster } from "../main/cluster";
import { LensExtension } from "./lens-extension";
import { getExtensionPageUrl } from "./registries/page-registry";
import { CommandRegistration } from "./registries/command-registry";
export class LensRendererExtension extends LensExtension {
globalPages: PageRegistration[] = [];
@@ -10,10 +11,10 @@ export class LensRendererExtension extends LensExtension {
clusterPageMenus: ClusterPageMenuRegistration[] = [];
kubeObjectStatusTexts: KubeObjectStatusRegistration[] = [];
appPreferences: AppPreferenceRegistration[] = [];
clusterFeatures: ClusterFeatureRegistration[] = [];
statusBarItems: StatusBarRegistration[] = [];
kubeObjectDetailItems: KubeObjectDetailRegistration[] = [];
kubeObjectMenuItems: KubeObjectMenuRegistration[] = [];
commands: CommandRegistration[] = [];
async navigate<P extends object>(pageId?: string, params?: P) {
const { navigate } = await import("../renderer/navigation");
@@ -29,8 +30,7 @@ export class LensRendererExtension extends LensExtension {
/**
* Defines if extension is enabled for a given cluster. Defaults to `true`.
*/
// eslint-disable-next-line unused-imports/no-unused-vars-ts
async isEnabledForCluster(cluster: Cluster): Promise<Boolean> {
return true;
return (void cluster) || true;
}
}
+1 -1
View File
@@ -3,7 +3,7 @@
"productName": "Lens extensions",
"description": "Lens - The Kubernetes IDE: extensions",
"version": "0.0.0",
"copyright": "© 2020, Mirantis, Inc.",
"copyright": "© 2021, Mirantis, Inc.",
"license": "MIT",
"main": "dist/src/extensions/extension-api.js",
"types": "dist/src/extensions/extension-api.d.ts",
@@ -8,10 +8,22 @@ export interface AppPreferenceComponents {
export interface AppPreferenceRegistration {
title: string;
id?: string;
showInPreferencesTab?: string;
components: AppPreferenceComponents;
}
export class AppPreferenceRegistry extends BaseRegistry<AppPreferenceRegistration> {
export interface RegisteredAppPreference extends AppPreferenceRegistration {
id: string;
}
export class AppPreferenceRegistry extends BaseRegistry<AppPreferenceRegistration, RegisteredAppPreference> {
getRegisteredItem(item: AppPreferenceRegistration): RegisteredAppPreference {
return {
id: item.id || item.title.toLowerCase().replace(/[^0-9a-zA-Z]+/g, "-"),
...item,
};
}
}
export const appPreferenceRegistry = new AppPreferenceRegistry();
@@ -1,18 +0,0 @@
import type React from "react";
import { BaseRegistry } from "./base-registry";
import { ClusterFeature } from "../cluster-feature";
export interface ClusterFeatureComponents {
Description: React.ComponentType<any>;
}
export interface ClusterFeatureRegistration {
title: string;
components: ClusterFeatureComponents
feature: ClusterFeature
}
export class ClusterFeatureRegistry extends BaseRegistry<ClusterFeatureRegistration> {
}
export const clusterFeatureRegistry = new ClusterFeatureRegistry();
@@ -0,0 +1,37 @@
// Extensions API -> Commands
import { BaseRegistry } from "./base-registry";
import { action, observable } from "mobx";
import { LensExtension } from "../lens-extension";
import { CatalogEntity } from "../../common/catalog-entity";
export type CommandContext = {
entity?: CatalogEntity;
};
export interface CommandRegistration {
id: string;
title: string;
scope: "entity" | "global";
action: (context: CommandContext) => void;
isActive?: (context: CommandContext) => boolean;
}
export class CommandRegistry extends BaseRegistry<CommandRegistration> {
@observable activeEntity: CatalogEntity;
@action
add(items: CommandRegistration | CommandRegistration[], extension?: LensExtension) {
const itemArray = [items].flat();
const newIds = itemArray.map((item) => item.id);
const currentIds = this.getItems().map((item) => item.id);
const filteredIds = newIds.filter((id) => !currentIds.includes(id));
const filteredItems = itemArray.filter((item) => filteredIds.includes(item.id));
return super.add(filteredItems, extension);
}
}
export const commandRegistry = new CommandRegistry();
+1 -1
View File
@@ -7,5 +7,5 @@ export * from "./app-preference-registry";
export * from "./status-bar-registry";
export * from "./kube-object-detail-registry";
export * from "./kube-object-menu-registry";
export * from "./cluster-feature-registry";
export * from "./kube-object-status-registry";
export * from "./command-registry";
@@ -0,0 +1,44 @@
/**
* ProtocolHandlerRegistration is the data required for an extension to register
* a handler to a specific path or dynamic path.
*/
export interface ProtocolHandlerRegistration {
pathSchema: string;
handler: RouteHandler;
}
/**
* The collection of the dynamic parts of a URI which initiated a `lens://`
* protocol request
*/
export interface RouteParams {
/**
* the parts of the URI query string
*/
search: Record<string, string>;
/**
* the matching parts of the path. The dynamic parts of the URI path.
*/
pathname: Record<string, string>;
/**
* if the most specific path schema that is matched does not cover the whole
* of the URI's path. Then this field will be set to the remaining path
* segments.
*
* Example:
*
* If the path schema `/landing/:type` is the matched schema for the URI
* `/landing/soft/easy` then this field will be set to `"/easy"`.
*/
tail?: string;
}
/**
* RouteHandler represents the function signature of the handler function for
* `lens://` protocol routing.
*/
export interface RouteHandler {
(params: RouteParams): void;
}
@@ -3,7 +3,18 @@
import React from "react";
import { BaseRegistry } from "./base-registry";
export interface StatusBarRegistration {
interface StatusBarComponents {
Item?: React.ComponentType;
}
interface StatusBarRegistrationV2 {
components?: StatusBarComponents; // has to be optional for backwards compatability
}
export interface StatusBarRegistration extends StatusBarRegistrationV2 {
/**
* @deprecated use components.Item instead
*/
item?: React.ReactNode;
}
+5 -1
View File
@@ -1,4 +1,5 @@
// Common UI components
export * from "../../renderer/components/layout/sub-title";
// layouts
export * from "../../renderer/components/layout/page-layout";
@@ -13,6 +14,9 @@ export * from "../../renderer/components/select";
export * from "../../renderer/components/slider";
export * from "../../renderer/components/input/input";
// command-overlay
export { CommandOverlay } from "../../renderer/components/command-palette";
// other components
export * from "../../renderer/components/icon";
export * from "../../renderer/components/tooltip";
@@ -38,4 +42,4 @@ export * from "../../renderer/components/+events/kube-event-details";
// specific exports
export * from "../../renderer/components/status-brick";
export { terminalStore, createTerminalTab } from "../../renderer/components/dock/terminal.store";
export { createPodLogsTab } from "../../renderer/components/dock/pod-logs.store";
export { logTabStore } from "../../renderer/components/dock/log-tab.store";
+2
View File
@@ -14,6 +14,7 @@ export { ConfigMap, configMapApi } from "../../renderer/api/endpoints";
export { Secret, secretsApi, ISecretRef } from "../../renderer/api/endpoints";
export { ReplicaSet, replicaSetApi } from "../../renderer/api/endpoints";
export { ResourceQuota, resourceQuotaApi } from "../../renderer/api/endpoints";
export { LimitRange, limitRangeApi } from "../../renderer/api/endpoints";
export { HorizontalPodAutoscaler, hpaApi } from "../../renderer/api/endpoints";
export { PodDisruptionBudget, pdbApi } from "../../renderer/api/endpoints";
export { Service, serviceApi } from "../../renderer/api/endpoints";
@@ -46,6 +47,7 @@ export type { ConfigMapsStore } from "../../renderer/components/+config-maps/con
export type { SecretsStore } from "../../renderer/components/+config-secrets/secrets.store";
export type { ReplicaSetStore } from "../../renderer/components/+workloads-replicasets/replicasets.store";
export type { ResourceQuotasStore } from "../../renderer/components/+config-resource-quotas/resource-quotas.store";
export type { LimitRangesStore } from "../../renderer/components/+config-limit-ranges/limit-ranges.store";
export type { HPAStore } from "../../renderer/components/+config-autoscalers/hpa.store";
export type { PodDisruptionBudgetsStore } from "../../renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets.store";
export type { ServiceStore } from "../../renderer/components/+network-services/services.store";
@@ -8,4 +8,4 @@ export enum KubeObjectStatusLevel {
INFO = 1,
WARNING = 2,
CRITICAL = 3
}
}
+1 -1
View File
@@ -2,4 +2,4 @@ import { themeStore } from "../../renderer/theme.store";
export function getActiveTheme() {
return themeStore.activeTheme;
}
}
-128
View File
@@ -1,128 +0,0 @@
import { clusterStore as internalClusterStore, ClusterId } from "../../common/cluster-store";
import type { ClusterModel } from "../../common/cluster-store";
import { Cluster } from "../../main/cluster";
import { Singleton } from "../core-api/utils";
import { ObservableMap } from "mobx";
export { Cluster } from "../../main/cluster";
export type { ClusterModel, ClusterId } from "../../common/cluster-store";
/**
* Store for all added clusters
*
* @beta
*/
export class ClusterStore extends Singleton {
/**
* Active cluster id
*/
get activeClusterId(): string {
return internalClusterStore.activeCluster;
}
/**
* Set active cluster id
*/
set activeClusterId(id : ClusterId) {
internalClusterStore.activeCluster = id;
}
/**
* Map of all clusters
*/
get clusters(): ObservableMap<string, Cluster> {
return internalClusterStore.clusters;
}
/**
* Get active cluster (a cluster which is currently visible)
*/
get activeCluster(): Cluster {
if (!this.activeClusterId) {
return null;
}
return this.getById(this.activeClusterId);
}
/**
* Array of all clusters
*/
get clustersList(): Cluster[] {
return internalClusterStore.clustersList;
}
/**
* Array of all enabled clusters
*/
get enabledClustersList(): Cluster[] {
return internalClusterStore.enabledClustersList;
}
/**
* Array of all clusters that have active connection to a Kubernetes cluster
*/
get connectedClustersList(): Cluster[] {
return internalClusterStore.connectedClustersList;
}
/**
* Get cluster object by cluster id
* @param id cluster id
*/
getById(id: ClusterId): Cluster {
return internalClusterStore.getById(id);
}
/**
* Get all clusters belonging to a workspace
* @param workspaceId workspace id
*/
getByWorkspaceId(workspaceId: string): Cluster[] {
return internalClusterStore.getByWorkspaceId(workspaceId);
}
/**
* Add clusters to store
* @param models list of cluster models
*/
addClusters(...models: ClusterModel[]): Cluster[] {
return internalClusterStore.addClusters(...models);
}
/**
* Add a cluster to store
* @param model cluster
*/
addCluster(model: ClusterModel | Cluster): Cluster {
return internalClusterStore.addCluster(model);
}
/**
* Remove a cluster from store
* @param model cluster
*/
async removeCluster(model: ClusterModel) {
return this.removeById(model.id);
}
/**
* Remove a cluster from store by id
* @param clusterId cluster id
*/
async removeById(clusterId: ClusterId) {
return internalClusterStore.removeById(clusterId);
}
/**
* Remove all clusters belonging to a workspaces
* @param workspaceId workspace id
*/
removeByWorkspaceId(workspaceId: string) {
return internalClusterStore.removeByWorkspaceId(workspaceId);
}
}
export const clusterStore = ClusterStore.getInstance<ClusterStore>();
-118
View File
@@ -1,118 +0,0 @@
import { Singleton } from "../core-api/utils";
import { workspaceStore as internalWorkspaceStore, WorkspaceStore as InternalWorkspaceStore, Workspace, WorkspaceId } from "../../common/workspace-store";
import { ObservableMap } from "mobx";
export { Workspace } from "../../common/workspace-store";
export type { WorkspaceId, WorkspaceModel } from "../../common/workspace-store";
/**
* Stores all workspaces
*
* @beta
*/
export class WorkspaceStore extends Singleton {
/**
* Default workspace id, this workspace is always present
*/
static readonly defaultId: WorkspaceId = InternalWorkspaceStore.defaultId;
/**
* Currently active workspace id
*/
get currentWorkspaceId(): string {
return internalWorkspaceStore.currentWorkspaceId;
}
/**
* Set active workspace id
*/
set currentWorkspaceId(id: string) {
internalWorkspaceStore.currentWorkspaceId = id;
}
/**
* Map of all workspaces
*/
get workspaces(): ObservableMap<string, Workspace> {
return internalWorkspaceStore.workspaces;
}
/**
* Currently active workspace
*/
get currentWorkspace(): Workspace {
return internalWorkspaceStore.currentWorkspace;
}
/**
* Array of all workspaces
*/
get workspacesList(): Workspace[] {
return internalWorkspaceStore.workspacesList;
}
/**
* Array of all enabled (visible) workspaces
*/
get enabledWorkspacesList(): Workspace[] {
return internalWorkspaceStore.enabledWorkspacesList;
}
/**
* Get workspace by id
* @param id workspace id
*/
getById(id: WorkspaceId): Workspace {
return internalWorkspaceStore.getById(id);
}
/**
* Get workspace by name
* @param name workspace name
*/
getByName(name: string): Workspace {
return internalWorkspaceStore.getByName(name);
}
/**
* Set active workspace
* @param id workspace id
*/
setActive(id = WorkspaceStore.defaultId) {
return internalWorkspaceStore.setActive(id);
}
/**
* Add a workspace to store
* @param workspace workspace
*/
addWorkspace(workspace: Workspace) {
return internalWorkspaceStore.addWorkspace(workspace);
}
/**
* Update a workspace in store
* @param workspace workspace
*/
updateWorkspace(workspace: Workspace) {
return internalWorkspaceStore.updateWorkspace(workspace);
}
/**
* Remove workspace from store
* @param workspace workspace
*/
removeWorkspace(workspace: Workspace) {
return internalWorkspaceStore.removeWorkspace(workspace);
}
/**
* Remove workspace by id
* @param id workspace
*/
removeWorkspaceById(id: WorkspaceId) {
return internalWorkspaceStore.removeWorkspaceById(id);
}
}
export const workspaceStore = WorkspaceStore.getInstance<WorkspaceStore>();
+4
View File
@@ -4,3 +4,7 @@ fetchMock.enableMocks();
// Mock __non_webpack_require__ for tests
globalThis.__non_webpack_require__ = jest.fn();
process.on("unhandledRejection", (err) => {
fail(err);
});
+3 -5
View File
@@ -31,7 +31,6 @@ jest.mock("request-promise-native");
import { Console } from "console";
import mockFs from "mock-fs";
import { workspaceStore } from "../../common/workspace-store";
import { Cluster } from "../cluster";
import { ContextHandler } from "../context-handler";
import { getFreePort } from "../port";
@@ -81,8 +80,7 @@ describe("create clusters", () => {
c = new Cluster({
id: "foo",
contextName: "minikube",
kubeConfigPath: "minikube-config.yml",
workspace: workspaceStore.currentWorkspaceId
kubeConfigPath: "minikube-config.yml"
});
});
@@ -126,6 +124,7 @@ describe("create clusters", () => {
};
jest.spyOn(Cluster.prototype, "isClusterAdmin").mockReturnValue(Promise.resolve(true));
jest.spyOn(Cluster.prototype, "canUseWatchApi").mockReturnValue(Promise.resolve(true));
jest.spyOn(Cluster.prototype, "canI")
.mockImplementation((attr: V1ResourceAttributes): Promise<boolean> => {
expect(attr.namespace).toBe("default");
@@ -161,8 +160,7 @@ describe("create clusters", () => {
}({
id: "foo",
contextName: "minikube",
kubeConfigPath: "minikube-config.yml",
workspace: workspaceStore.currentWorkspaceId
kubeConfigPath: "minikube-config.yml"
});
await c.init(port);
+8 -9
View File
@@ -26,7 +26,6 @@ jest.mock("winston", () => ({
import { KubeconfigManager } from "../kubeconfig-manager";
import mockFs from "mock-fs";
import { Cluster } from "../cluster";
import { workspaceStore } from "../../common/workspace-store";
import { ContextHandler } from "../context-handler";
import { getFreePort } from "../port";
import fse from "fs-extra";
@@ -77,16 +76,17 @@ describe("kubeconfig manager tests", () => {
const cluster = new Cluster({
id: "foo",
contextName: "minikube",
kubeConfigPath: "minikube-config.yml",
workspace: workspaceStore.currentWorkspaceId
kubeConfigPath: "minikube-config.yml"
});
const contextHandler = new ContextHandler(cluster);
const port = await getFreePort();
const kubeConfManager = await KubeconfigManager.create(cluster, contextHandler, port);
expect(logger.error).not.toBeCalled();
expect(kubeConfManager.getPath()).toBe(`tmp${path.sep}kubeconfig-foo`);
const file = await fse.readFile(kubeConfManager.getPath());
expect(await kubeConfManager.getPath()).toBe(`tmp${path.sep}kubeconfig-foo`);
// this causes an intermittent "ENXIO: no such device or address, read" error
// const file = await fse.readFile(await kubeConfManager.getPath());
const file = fse.readFileSync(await kubeConfManager.getPath());
const yml = loadYaml<any>(file.toString());
expect(yml["current-context"]).toBe("minikube");
@@ -98,18 +98,17 @@ describe("kubeconfig manager tests", () => {
const cluster = new Cluster({
id: "foo",
contextName: "minikube",
kubeConfigPath: "minikube-config.yml",
workspace: workspaceStore.currentWorkspaceId
kubeConfigPath: "minikube-config.yml"
});
const contextHandler = new ContextHandler(cluster);
const port = await getFreePort();
const kubeConfManager = await KubeconfigManager.create(cluster, contextHandler, port);
const configPath = kubeConfManager.getPath();
const configPath = await kubeConfManager.getPath();
expect(await fse.pathExists(configPath)).toBe(true);
await kubeConfManager.unlink();
expect(await fse.pathExists(configPath)).toBe(false);
await kubeConfManager.unlink(); // doesn't throw
expect(kubeConfManager.getPath()).toBeUndefined();
expect(await kubeConfManager.getPath()).toBeUndefined();
});
});
+40
View File
@@ -0,0 +1,40 @@
import { Router } from "../router";
const staticRoot = __dirname;
class TestRouter extends Router {
protected resolveStaticRootPath() {
return staticRoot;
}
}
describe("Router", () => {
it("blocks path traversal attacks", async () => {
const router = new TestRouter();
const res = {
statusCode: 200,
end: jest.fn()
};
await router.handleStaticFile("../index.ts", res as any, {} as any, 0);
expect(res.statusCode).toEqual(404);
});
it("serves files under static root", async () => {
const router = new TestRouter();
const res = {
statusCode: 200,
write: jest.fn(),
setHeader: jest.fn(),
end: jest.fn()
};
const req = {
url: ""
};
await router.handleStaticFile("router.test.ts", res as any, req as any, 0);
expect(res.statusCode).toEqual(200);
});
});
+19
View File
@@ -0,0 +1,19 @@
/**
* @jest-environment jsdom
*/
import { clearKubeconfigEnvVars } from "../utils/clear-kube-env-vars";
describe("clearKubeconfigEnvVars tests", () => {
it("should not touch non kubeconfig keys", () => {
expect(clearKubeconfigEnvVars({ a: 1 })).toStrictEqual({ a: 1 });
});
it("should remove a single kubeconfig key", () => {
expect(clearKubeconfigEnvVars({ a: 1, kubeconfig: "1" })).toStrictEqual({ a: 1 });
});
it("should remove a two kubeconfig key", () => {
expect(clearKubeconfigEnvVars({ a: 1, kubeconfig: "1", kUbeconfig: "1" })).toStrictEqual({ a: 1 });
});
});
+89 -10
View File
@@ -1,20 +1,99 @@
import { autoUpdater } from "electron-updater";
import { autoUpdater, UpdateInfo } from "electron-updater";
import logger from "./logger";
import { isDevelopment, isTestEnv } from "../common/vars";
import { delay } from "../common/utils";
import { areArgsUpdateAvailableToBackchannel, AutoUpdateLogPrefix, broadcastMessage, onceCorrect, UpdateAvailableChannel, UpdateAvailableToBackchannel } from "../common/ipc";
import { once } from "lodash";
import { app, ipcMain } from "electron";
export class AppUpdater {
static readonly defaultUpdateIntervalMs = 1000 * 60 * 60 * 24; // once a day
let installVersion: null | string = null;
static checkForUpdates() {
return autoUpdater.checkForUpdatesAndNotify();
function handleAutoUpdateBackChannel(event: Electron.IpcMainEvent, ...[arg]: UpdateAvailableToBackchannel) {
if (arg.doUpdate) {
if (arg.now) {
logger.info(`${AutoUpdateLogPrefix}: User chose to update now`);
autoUpdater.quitAndInstall(true, true);
app.exit(); // this is needed for the installer not to fail on windows.
} else {
logger.info(`${AutoUpdateLogPrefix}: User chose to update on quit`);
autoUpdater.autoInstallOnAppQuit = true;
}
} else {
logger.info(`${AutoUpdateLogPrefix}: User chose not to update`);
}
}
constructor(protected updateInterval = AppUpdater.defaultUpdateIntervalMs) {
autoUpdater.logger = logger;
/**
* starts the automatic update checking
* @param interval milliseconds between interval to check on, defaults to 24h
*/
export const startUpdateChecking = once(function (interval = 1000 * 60 * 60 * 24): void {
if (isDevelopment || isTestEnv) {
return;
}
public start() {
setInterval(AppUpdater.checkForUpdates, this.updateInterval);
autoUpdater.logger = logger;
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = false;
return AppUpdater.checkForUpdates();
autoUpdater
.on("update-available", (info: UpdateInfo) => {
if (autoUpdater.autoInstallOnAppQuit) {
// a previous auto-update loop was completed with YES+LATER, check if same version
if (installVersion === info.version) {
// same version, don't broadcast
return;
}
}
/**
* This should be always set to false here because it is the reasonable
* default. Namely, if a don't auto update to a version that the user
* didn't ask for.
*/
autoUpdater.autoInstallOnAppQuit = false;
installVersion = info.version;
autoUpdater.downloadUpdate()
.catch(error => logger.error(`${AutoUpdateLogPrefix}: failed to download update`, { error: String(error) }));
})
.on("update-downloaded", (info: UpdateInfo) => {
try {
const backchannel = `auto-update:${info.version}`;
ipcMain.removeAllListeners(backchannel); // only one handler should be present
// make sure that the handler is in place before broadcasting (prevent race-condition)
onceCorrect({
source: ipcMain,
channel: backchannel,
listener: handleAutoUpdateBackChannel,
verifier: areArgsUpdateAvailableToBackchannel,
});
logger.info(`${AutoUpdateLogPrefix}: broadcasting update available`, { backchannel, version: info.version });
broadcastMessage(UpdateAvailableChannel, backchannel, info);
} catch (error) {
logger.error(`${AutoUpdateLogPrefix}: broadcasting failed`, { error });
installVersion = undefined;
}
});
async function helper() {
while (true) {
await checkForUpdates();
await delay(interval);
}
}
helper();
});
export async function checkForUpdates(): Promise<void> {
try {
logger.info(`📡 Checking for app updates`);
await autoUpdater.checkForUpdates();
} catch (error) {
logger.error(`${AutoUpdateLogPrefix}: failed with an error`, { error: String(error) });
}
}
+32
View File
@@ -0,0 +1,32 @@
import { autorun, toJS } from "mobx";
import { broadcastMessage, subscribeToBroadcast, unsubscribeFromBroadcast } from "../common/ipc";
import { CatalogEntityRegistry} from "../common/catalog-entity-registry";
import "../common/catalog-entities/kubernetes-cluster";
export class CatalogPusher {
static init(catalog: CatalogEntityRegistry) {
new CatalogPusher(catalog).init();
}
private constructor(private catalog: CatalogEntityRegistry) {}
init() {
const disposers: { (): void; }[] = [];
disposers.push(autorun(() => {
this.broadcast();
}));
const listener = subscribeToBroadcast("catalog:broadcast", () => {
this.broadcast();
});
disposers.push(() => unsubscribeFromBroadcast("catalog:broadcast", listener));
return disposers;
}
broadcast() {
broadcastMessage("catalog:items", toJS(this.catalog.items, { recurseEverything: true }));
}
}
@@ -31,4 +31,4 @@ export class BaseClusterDetector {
},
});
}
}
}
@@ -23,4 +23,4 @@ export class ClusterIdDetector extends BaseClusterDetector {
return response.metadata.uid;
}
}
}
@@ -48,4 +48,4 @@ detectorRegistry.add(ClusterIdDetector);
detectorRegistry.add(LastSeenDetector);
detectorRegistry.add(VersionDetector);
detectorRegistry.add(DistributionDetector);
detectorRegistry.add(NodesCountDetector);
detectorRegistry.add(NodesCountDetector);
@@ -11,4 +11,4 @@ export class LastSeenDetector extends BaseClusterDetector {
return { value: new Date().toJSON(), accuracy: 100 };
}
}
}
@@ -16,4 +16,4 @@ export class NodesCountDetector extends BaseClusterDetector {
return response.items.length;
}
}
}
@@ -16,4 +16,4 @@ export class VersionDetector extends BaseClusterDetector {
return response.gitVersion;
}
}
}
+107 -4
View File
@@ -1,26 +1,45 @@
import "../common/cluster-ipc";
import type http from "http";
import { ipcMain } from "electron";
import { autorun } from "mobx";
import { action, autorun, observable, reaction, toJS } from "mobx";
import { clusterStore, getClusterIdFromHost } from "../common/cluster-store";
import { Cluster } from "./cluster";
import logger from "./logger";
import { apiKubePrefix } from "../common/vars";
import { Singleton } from "../common/utils";
import { CatalogEntity } from "../common/catalog-entity";
import { KubernetesCluster } from "../common/catalog-entities/kubernetes-cluster";
import { catalogEntityRegistry } from "../common/catalog-entity-registry";
const clusterOwnerRef = "ClusterManager";
export class ClusterManager extends Singleton {
catalogSource = observable.array<CatalogEntity>([]);
constructor(public readonly port: number) {
super();
catalogEntityRegistry.addSource("lens:kubernetes-clusters", this.catalogSource);
// auto-init clusters
autorun(() => {
clusterStore.enabledClustersList.forEach(cluster => {
if (!cluster.initialized) {
reaction(() => clusterStore.enabledClustersList, (clusters) => {
clusters.forEach((cluster) => {
if (!cluster.initialized && !cluster.initializing) {
logger.info(`[CLUSTER-MANAGER]: init cluster`, cluster.getMeta());
cluster.init(port);
}
});
}, { fireImmediately: true });
reaction(() => toJS(clusterStore.enabledClustersList, { recurseEverything: true }), () => {
this.updateCatalogSource(clusterStore.enabledClustersList);
}, { fireImmediately: true });
reaction(() => catalogEntityRegistry.getItemsForApiKind<KubernetesCluster>("entity.k8slens.dev/v1alpha1", "KubernetesCluster"), (entities) => {
this.syncClustersFromCatalog(entities);
});
// auto-stop removed clusters
autorun(() => {
const removedClusters = Array.from(clusterStore.removedClusters.values());
@@ -40,6 +59,90 @@ export class ClusterManager extends Singleton {
ipcMain.on("network:online", () => { this.onNetworkOnline(); });
}
@action protected updateCatalogSource(clusters: Cluster[]) {
this.catalogSource.forEach((entity, index) => {
const clusterIndex = clusters.findIndex((cluster) => entity.metadata.uid === cluster.id);
if (clusterIndex === -1) {
this.catalogSource.splice(index, 1);
}
});
clusters.filter((c) => !c.ownerRef).forEach((cluster) => {
const entityIndex = this.catalogSource.findIndex((entity) => entity.metadata.uid === cluster.id);
const newEntity = this.catalogEntityFromCluster(cluster);
if (entityIndex === -1) {
this.catalogSource.push(newEntity);
} else {
const oldEntity = this.catalogSource[entityIndex];
newEntity.status.phase = cluster.disconnected ? "disconnected" : "connected";
newEntity.status.active = !cluster.disconnected;
newEntity.metadata.labels = {
...newEntity.metadata.labels,
...oldEntity.metadata.labels
};
this.catalogSource.splice(entityIndex, 1, newEntity);
}
});
}
@action syncClustersFromCatalog(entities: KubernetesCluster[]) {
entities.filter((entity) => entity.metadata.source !== "local").forEach((entity: KubernetesCluster) => {
const cluster = clusterStore.getById(entity.metadata.uid);
if (!cluster) {
clusterStore.addCluster({
id: entity.metadata.uid,
enabled: true,
ownerRef: clusterOwnerRef,
preferences: {
clusterName: entity.metadata.name
},
kubeConfigPath: entity.spec.kubeconfigPath,
contextName: entity.spec.kubeconfigContext
});
} else {
cluster.enabled = true;
if (!cluster.ownerRef) cluster.ownerRef = clusterOwnerRef;
cluster.preferences.clusterName = entity.metadata.name;
cluster.kubeConfigPath = entity.spec.kubeconfigPath;
cluster.contextName = entity.spec.kubeconfigContext;
entity.status = {
phase: cluster.disconnected ? "disconnected" : "connected",
active: !cluster.disconnected
};
}
});
}
protected catalogEntityFromCluster(cluster: Cluster) {
return new KubernetesCluster(toJS({
apiVersion: "entity.k8slens.dev/v1alpha1",
kind: "KubernetesCluster",
metadata: {
uid: cluster.id,
name: cluster.name,
source: "local",
labels: {
"distro": (cluster.metadata["distribution"] || "unknown").toString()
}
},
spec: {
kubeconfigPath: cluster.kubeConfigPath,
kubeconfigContext: cluster.contextName
},
status: {
phase: cluster.disconnected ? "disconnected" : "connected",
reason: "",
message: "",
active: !cluster.disconnected
}
}));
}
protected onNetworkOffline() {
logger.info("[CLUSTER-MANAGER]: network is offline");
clusterStore.enabledClustersList.forEach((cluster) => {
+106 -40
View File
@@ -1,15 +1,14 @@
import { ipcMain } from "electron";
import type { ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences, ClusterPrometheusPreferences } from "../common/cluster-store";
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, ClusterListNamespaceForbiddenChannel } from "../common/ipc";
import { ContextHandler } from "./context-handler";
import { AuthorizationV1Api, CoreV1Api, KubeConfig, V1ResourceAttributes } from "@kubernetes/client-node";
import { AuthorizationV1Api, CoreV1Api, HttpError, 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";
@@ -48,6 +47,7 @@ export interface ClusterState {
isAdmin: boolean;
allowedNamespaces: string[]
allowedResources: string[]
isGlobalWatchEnabled: boolean;
}
/**
@@ -84,6 +84,13 @@ export class Cluster implements ClusterModel, ClusterState {
whenInitialized = when(() => this.initialized);
whenReady = when(() => this.ready);
/**
* Is cluster object initializinng on-going
*
* @observable
*/
@observable initializing = false;
/**
* Is cluster object initialized
*
@@ -96,18 +103,16 @@ export class Cluster implements ClusterModel, ClusterState {
* @observable
*/
@observable contextName: string;
/**
* Workspace id
*
* @observable
*/
@observable workspace: WorkspaceId;
/**
* Path to kubeconfig
*
* @observable
*/
@observable kubeConfigPath: string;
/**
* @deprecated
*/
@observable workspace: string;
/**
* Kubernetes API server URL
*
@@ -169,6 +174,13 @@ export class Cluster implements ClusterModel, ClusterState {
* @observable
*/
@observable isAdmin = false;
/**
* Global watch-api accessibility , e.g. "/api/v1/services?watch=1"
*
* @observable
*/
@observable isGlobalWatchEnabled = false;
/**
* Preferences
*
@@ -182,7 +194,7 @@ export class Cluster implements ClusterModel, ClusterState {
*/
@observable metadata: ClusterMetadata = {};
/**
* List of allowed namespaces
* List of allowed namespaces verified via K8S::SelfSubjectAccessReview api
*
* @observable
*/
@@ -195,7 +207,7 @@ export class Cluster implements ClusterModel, ClusterState {
*/
@observable allowedResources: string[] = [];
/**
* List of accessible namespaces
* List of accessible namespaces provided by user in the Cluster Settings
*
* @observable
*/
@@ -216,7 +228,7 @@ export class Cluster implements ClusterModel, ClusterState {
* @computed
*/
@computed get name() {
return this.preferences.clusterName || this.contextName;
return this.preferences.clusterName || this.contextName;
}
/**
@@ -237,15 +249,21 @@ export class Cluster implements ClusterModel, ClusterState {
* Kubernetes version
*/
get version(): string {
return String(this.metadata?.version) || "";
return String(this.metadata?.version ?? "");
}
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);
}
}
@@ -271,8 +289,10 @@ export class Cluster implements ClusterModel, ClusterState {
* @param port port where internal auth proxy is listening
* @internal
*/
@action async init(port: number) {
@action
async init(port: number) {
try {
this.initializing = true;
this.contextHandler = new ContextHandler(this);
this.kubeconfigManager = await KubeconfigManager.create(this, this.contextHandler, port);
this.kubeProxyUrl = `http://localhost:${port}${apiKubePrefix}`;
@@ -287,6 +307,8 @@ export class Cluster implements ClusterModel, ClusterState {
id: this.id,
error: err,
});
} finally {
this.initializing = false;
}
}
@@ -323,7 +345,8 @@ export class Cluster implements ClusterModel, ClusterState {
* @param force force activation
* @internal
*/
@action async activate(force = false) {
@action
async activate(force = false) {
if (this.activated && !force) {
return this.pushState();
}
@@ -340,9 +363,7 @@ export class Cluster implements ClusterModel, ClusterState {
await this.refreshConnectionStatus();
if (this.accessible) {
await this.refreshAllowedResources();
this.isAdmin = await this.isClusterAdmin();
this.ready = true;
await this.refreshAccessibility();
this.ensureKubectl();
}
this.activated = true;
@@ -362,7 +383,8 @@ export class Cluster implements ClusterModel, ClusterState {
/**
* @internal
*/
@action async reconnect() {
@action
async reconnect() {
logger.info(`[CLUSTER]: reconnect`, this.getMeta());
this.contextHandler?.stopServer();
await this.contextHandler?.ensureServer();
@@ -381,6 +403,7 @@ export class Cluster implements ClusterModel, ClusterState {
this.accessible = false;
this.ready = false;
this.activated = false;
this.allowedNamespaces = [];
this.resourceAccessStatuses.clear();
this.pushState();
}
@@ -389,19 +412,18 @@ export class Cluster implements ClusterModel, ClusterState {
* @internal
* @param opts refresh options
*/
@action async refresh(opts: ClusterRefreshOptions = {}) {
@action
async refresh(opts: ClusterRefreshOptions = {}) {
logger.info(`[CLUSTER]: refresh`, this.getMeta());
await this.whenInitialized;
await this.refreshConnectionStatus();
if (this.accessible) {
this.isAdmin = await this.isClusterAdmin();
await this.refreshAllowedResources();
await this.refreshAccessibility();
if (opts.refreshMetadata) {
this.refreshMetadata();
}
this.ready = true;
}
this.pushState();
}
@@ -409,7 +431,8 @@ export class Cluster implements ClusterModel, ClusterState {
/**
* @internal
*/
@action async refreshMetadata() {
@action
async refreshMetadata() {
logger.info(`[CLUSTER]: refreshMetadata`, this.getMeta());
const metadata = await detectorRegistry.detectForCluster(this);
const existingMetadata = this.metadata;
@@ -420,7 +443,20 @@ export class Cluster implements ClusterModel, ClusterState {
/**
* @internal
*/
@action async refreshConnectionStatus() {
private async refreshAccessibility(): Promise<void> {
this.isAdmin = await this.isClusterAdmin();
this.isGlobalWatchEnabled = await this.canUseWatchApi({ resource: "*" });
await this.refreshAllowedResources();
this.ready = true;
}
/**
* @internal
*/
@action
async refreshConnectionStatus() {
const connectionStatus = await this.getConnectionStatus();
this.online = connectionStatus > ClusterStatus.Offline;
@@ -430,7 +466,8 @@ export class Cluster implements ClusterModel, ClusterState {
/**
* @internal
*/
@action async refreshAllowedResources() {
@action
async refreshAllowedResources() {
this.allowedNamespaces = await this.getAllowedNamespaces();
this.allowedResources = await this.getAllowedResources();
}
@@ -442,14 +479,16 @@ export class Cluster implements ClusterModel, ClusterState {
/**
* @internal
*/
getProxyKubeconfig(): KubeConfig {
return loadConfig(this.getProxyKubeconfigPath());
async getProxyKubeconfig(): Promise<KubeConfig> {
const kubeconfigPath = await this.getProxyKubeconfigPath();
return loadConfig(kubeconfigPath);
}
/**
* @internal
*/
getProxyKubeconfigPath(): string {
async getProxyKubeconfigPath(): Promise<string> {
return this.kubeconfigManager.getPath();
}
@@ -476,7 +515,8 @@ export class Cluster implements ClusterModel, ClusterState {
timeout: 0,
resolveWithFullResponse: false,
json: true,
qs: queryParams,
method: "POST",
form: queryParams,
});
}
@@ -525,7 +565,7 @@ export class Cluster implements ClusterModel, ClusterState {
* @param resourceAttributes resource attributes
*/
async canI(resourceAttributes: V1ResourceAttributes): Promise<boolean> {
const authApi = this.getProxyKubeconfig().makeApiClient(AuthorizationV1Api);
const authApi = (await this.getProxyKubeconfig()).makeApiClient(AuthorizationV1Api);
try {
const accessReview = await authApi.createSelfSubjectAccessReview({
@@ -553,6 +593,17 @@ export class Cluster implements ClusterModel, ClusterState {
});
}
/**
* @internal
*/
async canUseWatchApi(customizeResource: V1ResourceAttributes = {}): Promise<boolean> {
return this.canI({
verb: "watch",
resource: "*",
...customizeResource,
});
}
toJSON(): ClusterModel {
const model: ClusterModel = {
id: this.id,
@@ -586,6 +637,7 @@ export class Cluster implements ClusterModel, ClusterState {
isAdmin: this.isAdmin,
allowedNamespaces: this.allowedNamespaces,
allowedResources: this.allowedResources,
isGlobalWatchEnabled: this.isGlobalWatchEnabled,
};
return toJS(state, {
@@ -628,18 +680,22 @@ export class Cluster implements ClusterModel, ClusterState {
return this.accessibleNamespaces;
}
const api = this.getProxyKubeconfig().makeApiClient(CoreV1Api);
const api = (await this.getProxyKubeconfig()).makeApiClient(CoreV1Api);
try {
const namespaceList = await api.listNamespace();
return namespaceList.body.items.map(ns => ns.metadata.name);
} catch (error) {
const ctx = this.getProxyKubeconfig().getContextObject(this.contextName);
const ctx = (await this.getProxyKubeconfig()).getContextObject(this.contextName);
const namespaceList = [ctx.namespace].filter(Boolean);
if (ctx.namespace) return [ctx.namespace];
if (namespaceList.length === 0 && error instanceof HttpError && error.statusCode === 403) {
logger.info("[CLUSTER]: listing namespaces is forbidden, broadcasting", { clusterId: this.id });
broadcastMessage(ClusterListNamespaceForbiddenChannel, this.id);
}
return [];
return namespaceList;
}
}
@@ -657,7 +713,7 @@ export class Cluster implements ClusterModel, ClusterState {
for (const namespace of this.allowedNamespaces.slice(0, 10)) {
if (!this.resourceAccessStatuses.get(apiResource)) {
const result = await this.canI({
resource: apiResource.resource,
resource: apiResource.apiName,
group: apiResource.group,
verb: "list",
namespace
@@ -672,9 +728,19 @@ export class Cluster implements ClusterModel, ClusterState {
return apiResources
.filter((resource) => this.resourceAccessStatuses.get(resource))
.map(apiResource => apiResource.resource);
.map(apiResource => apiResource.apiName);
} catch (error) {
return [];
}
}
isAllowedResource(kind: string): boolean {
const apiResource = apiResources.find(resource => resource.kind === kind || resource.apiName === kind);
if (apiResource) {
return this.allowedResources.includes(apiResource.apiName);
}
return true; // allowed by default for other resources
}
}
+1 -1
View File
@@ -59,7 +59,7 @@ export class ContextHandler {
async getPrometheusService(): Promise<PrometheusService> {
const providers = this.prometheusProvider ? prometheusProviders.filter(provider => provider.id == this.prometheusProvider) : prometheusProviders;
const prometheusPromises: Promise<PrometheusService>[] = providers.map(async (provider: PrometheusProvider): Promise<PrometheusService> => {
const apiClient = this.cluster.getProxyKubeconfig().makeApiClient(CoreV1Api);
const apiClient = (await this.cluster.getProxyKubeconfig()).makeApiClient(CoreV1Api);
return await provider.getPrometheusService(apiClient);
});
+3
View File
@@ -1,9 +1,12 @@
import logger from "./logger";
/**
* Installs Electron developer tools in the development build.
* The dependency is not bundled to the production build.
*/
export const installDeveloperTools = async () => {
if (process.env.NODE_ENV === "development") {
logger.info("🤓 Installing developer tools");
const { default: devToolsInstaller, REACT_DEVELOPER_TOOLS } = await import("electron-devtools-installer");
return devToolsInstaller([REACT_DEVELOPER_TOOLS]);
@@ -0,0 +1,108 @@
import { HelmRepo, HelmRepoManager } from "../helm-repo-manager";
export class HelmChartManager {
private cache: any = {};
private repo: HelmRepo;
constructor(repo: HelmRepo){
this.cache = HelmRepoManager.cache;
this.repo = repo;
}
public async charts(): Promise<any> {
switch (this.repo.name) {
case "stable":
return Promise.resolve({
"apm-server": [
{
apiVersion: "3.0.0",
name: "apm-server",
version: "2.1.7",
repo: "stable",
digest: "test"
},
{
apiVersion: "3.0.0",
name: "apm-server",
version: "2.1.6",
repo: "stable",
digest: "test"
}
],
"redis": [
{
apiVersion: "3.0.0",
name: "apm-server",
version: "1.0.0",
repo: "stable",
digest: "test"
},
{
apiVersion: "3.0.0",
name: "apm-server",
version: "0.0.9",
repo: "stable",
digest: "test"
}
]
});
case "experiment":
return Promise.resolve({
"fairwind": [
{
apiVersion: "3.0.0",
name: "fairwind",
version: "0.0.1",
repo: "experiment",
digest: "test"
},
{
apiVersion: "3.0.0",
name: "fairwind",
version: "0.0.2",
repo: "experiment",
digest: "test",
deprecated: true
}
]
});
case "bitnami":
return Promise.resolve({
"hotdog": [
{
apiVersion: "3.0.0",
name: "hotdog",
version: "1.0.1",
repo: "bitnami",
digest: "test"
},
{
apiVersion: "3.0.0",
name: "hotdog",
version: "1.0.2",
repo: "bitnami",
digest: "test",
}
],
"pretzel": [
{
apiVersion: "3.0.0",
name: "pretzel",
version: "1.0",
repo: "bitnami",
digest: "test",
},
{
apiVersion: "3.0.0",
name: "pretzel",
version: "1.0.1",
repo: "bitnami",
digest: "test"
}
]
});
default:
return Promise.resolve({});
}
}
}
@@ -0,0 +1,104 @@
import { helmService } from "../helm-service";
import { repoManager } from "../helm-repo-manager";
jest.spyOn(repoManager, "init").mockImplementation();
jest.mock("../helm-chart-manager");
describe("Helm Service tests", () => {
test("list charts without deprecated ones", async () => {
jest.spyOn(repoManager, "repositories").mockImplementation(async () => {
return [
{ name: "stable", url: "stableurl" },
{ name: "experiment", url: "experimenturl" }
];
});
const charts = await helmService.listCharts();
expect(charts).toEqual({
stable: {
"apm-server": [
{
apiVersion: "3.0.0",
name: "apm-server",
version: "2.1.7",
repo: "stable",
digest: "test"
},
{
apiVersion: "3.0.0",
name: "apm-server",
version: "2.1.6",
repo: "stable",
digest: "test"
}
],
"redis": [
{
apiVersion: "3.0.0",
name: "apm-server",
version: "1.0.0",
repo: "stable",
digest: "test"
},
{
apiVersion: "3.0.0",
name: "apm-server",
version: "0.0.9",
repo: "stable",
digest: "test"
}
]
},
experiment: {}
});
});
test("list charts sorted by version in descending order", async () => {
jest.spyOn(repoManager, "repositories").mockImplementation(async () => {
return [
{ name: "bitnami", url: "bitnamiurl" }
];
});
const charts = await helmService.listCharts();
expect(charts).toEqual({
bitnami: {
"hotdog": [
{
apiVersion: "3.0.0",
name: "hotdog",
version: "1.0.2",
repo: "bitnami",
digest: "test",
},
{
apiVersion: "3.0.0",
name: "hotdog",
version: "1.0.1",
repo: "bitnami",
digest: "test"
},
],
"pretzel": [
{
apiVersion: "3.0.0",
name: "pretzel",
version: "1.0.1",
repo: "bitnami",
digest: "test",
},
{
apiVersion: "3.0.0",
name: "pretzel",
version: "1.0",
repo: "bitnami",
digest: "test"
}
]
}
});
});
});
+5 -4
View File
@@ -4,9 +4,10 @@ import { HelmRepo, HelmRepoManager } from "./helm-repo-manager";
import logger from "../logger";
import { promiseExec } from "../promise-exec";
import { helmCli } from "./helm-cli";
import type { RepoHelmChartList } from "../../renderer/api/endpoints/helm-charts.api";
type CachedYaml = {
entries: any; // todo: types
entries: RepoHelmChartList
};
export class HelmChartManager {
@@ -24,15 +25,15 @@ export class HelmChartManager {
return charts[name];
}
public async charts(): Promise<any> {
public async charts(): Promise<RepoHelmChartList> {
try {
const cachedYaml = await this.cachedYaml();
return cachedYaml["entries"];
} catch(error) {
logger.error(error);
logger.error("HELM-CHART-MANAGER]: failed to list charts", { error });
return [];
return {};
}
}
+6 -3
View File
@@ -56,11 +56,12 @@ export class HelmReleaseManager {
public async upgradeRelease(name: string, chart: string, values: any, namespace: string, version: string, cluster: Cluster){
const helm = await helmCli.binaryPath();
const fileName = tempy.file({name: "values.yaml"});
const proxyKubeconfig = await cluster.getProxyKubeconfigPath();
await fs.promises.writeFile(fileName, yaml.safeDump(values));
try {
const { stdout } = await promiseExec(`"${helm}" upgrade ${name} ${chart} --version ${version} -f ${fileName} --namespace ${namespace} --kubeconfig ${cluster.getProxyKubeconfigPath()}`).catch((error) => { throw(error.stderr);});
const { stdout } = await promiseExec(`"${helm}" upgrade ${name} ${chart} --version ${version} -f ${fileName} --namespace ${namespace} --kubeconfig ${proxyKubeconfig}`).catch((error) => { throw(error.stderr);});
return {
log: stdout,
@@ -73,7 +74,9 @@ export class HelmReleaseManager {
public async getRelease(name: string, namespace: string, cluster: Cluster) {
const helm = await helmCli.binaryPath();
const { stdout } = await promiseExec(`"${helm}" status ${name} --output json --namespace ${namespace} --kubeconfig ${cluster.getProxyKubeconfigPath()}`).catch((error) => { throw(error.stderr);});
const proxyKubeconfig = await cluster.getProxyKubeconfigPath();
const { stdout } = await promiseExec(`"${helm}" status ${name} --output json --namespace ${namespace} --kubeconfig ${proxyKubeconfig}`).catch((error) => { throw(error.stderr);});
const release = JSON.parse(stdout);
release.resources = await this.getResources(name, namespace, cluster);
@@ -112,7 +115,7 @@ export class HelmReleaseManager {
protected async getResources(name: string, namespace: string, cluster: Cluster) {
const helm = await helmCli.binaryPath();
const kubectl = await cluster.kubeCtl.getPath();
const pathToKubeconfig = cluster.getProxyKubeconfigPath();
const pathToKubeconfig = await cluster.getProxyKubeconfigPath();
const { stdout } = await promiseExec(`"${helm}" get manifest ${name} --namespace ${namespace} --kubeconfig ${pathToKubeconfig} | "${kubectl}" get -n ${namespace} --kubeconfig ${pathToKubeconfig} -f - -o=json`).catch(() => {
return { stdout: JSON.stringify({items: []})};
});
+42 -23
View File
@@ -1,16 +1,20 @@
import semver from "semver";
import { Cluster } from "../cluster";
import logger from "../logger";
import { repoManager } from "./helm-repo-manager";
import { HelmChartManager } from "./helm-chart-manager";
import { releaseManager } from "./helm-release-manager";
import { HelmChartList, RepoHelmChartList } from "../../renderer/api/endpoints/helm-charts.api";
class HelmService {
public async installChart(cluster: Cluster, data: { chart: string; values: {}; name: string; namespace: string; version: string }) {
return await releaseManager.installChart(data.chart, data.values, data.name, data.namespace, data.version, cluster.getProxyKubeconfigPath());
const proxyKubeconfig = await cluster.getProxyKubeconfigPath();
return await releaseManager.installChart(data.chart, data.values, data.name, data.namespace, data.version, proxyKubeconfig);
}
public async listCharts() {
const charts: any = {};
const charts: HelmChartList = {};
await repoManager.init();
const repositories = await repoManager.repositories();
@@ -18,14 +22,10 @@ class HelmService {
for (const repo of repositories) {
charts[repo.name] = {};
const manager = new HelmChartManager(repo);
let entries = await manager.charts();
const sortedCharts = this.sortChartsByVersion(await manager.charts());
const enabledCharts = this.excludeDeprecatedChartGroups(sortedCharts);
entries = this.excludeDeprecated(entries);
for (const key in entries) {
entries[key] = entries[key][0];
}
charts[repo.name] = entries;
charts[repo.name] = enabledCharts;
}
return charts;
@@ -55,8 +55,9 @@ class HelmService {
public async listReleases(cluster: Cluster, namespace: string = null) {
await repoManager.init();
const proxyKubeconfig = await cluster.getProxyKubeconfigPath();
return await releaseManager.listReleases(cluster.getProxyKubeconfigPath(), namespace);
return await releaseManager.listReleases(proxyKubeconfig, namespace);
}
public async getRelease(cluster: Cluster, releaseName: string, namespace: string) {
@@ -66,21 +67,27 @@ class HelmService {
}
public async getReleaseValues(cluster: Cluster, releaseName: string, namespace: string) {
const proxyKubeconfig = await cluster.getProxyKubeconfigPath();
logger.debug("Fetch release values");
return await releaseManager.getValues(releaseName, namespace, cluster.getProxyKubeconfigPath());
return await releaseManager.getValues(releaseName, namespace, proxyKubeconfig);
}
public async getReleaseHistory(cluster: Cluster, releaseName: string, namespace: string) {
const proxyKubeconfig = await cluster.getProxyKubeconfigPath();
logger.debug("Fetch release history");
return await releaseManager.getHistory(releaseName, namespace, cluster.getProxyKubeconfigPath());
return await releaseManager.getHistory(releaseName, namespace, proxyKubeconfig);
}
public async deleteRelease(cluster: Cluster, releaseName: string, namespace: string) {
const proxyKubeconfig = await cluster.getProxyKubeconfigPath();
logger.debug("Delete release");
return await releaseManager.deleteRelease(releaseName, namespace, cluster.getProxyKubeconfigPath());
return await releaseManager.deleteRelease(releaseName, namespace, proxyKubeconfig);
}
public async updateRelease(cluster: Cluster, releaseName: string, namespace: string, data: { chart: string; values: {}; version: string }) {
@@ -90,26 +97,38 @@ class HelmService {
}
public async rollback(cluster: Cluster, releaseName: string, namespace: string, revision: number) {
const proxyKubeconfig = await cluster.getProxyKubeconfigPath();
logger.debug("Rollback release");
const output = await releaseManager.rollback(releaseName, namespace, revision, cluster.getProxyKubeconfigPath());
const output = await releaseManager.rollback(releaseName, namespace, revision, proxyKubeconfig);
return { message: output };
}
protected excludeDeprecated(entries: any) {
for (const key in entries) {
entries[key] = entries[key].filter((entry: any) => {
if (Array.isArray(entry)) {
return entry[0]["deprecated"] != true;
}
private excludeDeprecatedChartGroups(chartGroups: RepoHelmChartList) {
const groups = new Map(Object.entries(chartGroups));
return entry["deprecated"] != true;
for (const [chartName, charts] of groups) {
if (charts[0].deprecated) {
groups.delete(chartName);
}
}
return Object.fromEntries(groups);
}
private sortChartsByVersion(chartGroups: RepoHelmChartList) {
for (const key in chartGroups) {
chartGroups[key] = chartGroups[key].sort((first, second) => {
const firstVersion = semver.coerce(first.version || 0);
const secondVersion = semver.coerce(second.version || 0);
return semver.compare(secondVersion, firstVersion);
});
}
return entries;
return chartGroups;
}
}
export const helmService = new HelmService();
+112 -20
View File
@@ -4,13 +4,12 @@ import "../common/system-ca";
import "../common/prometheus-providers";
import * as Mobx from "mobx";
import * as LensExtensions from "../extensions/core-api";
import { app, dialog, powerMonitor } from "electron";
import { appName } from "../common/vars";
import { app, autoUpdater, ipcMain, dialog, powerMonitor } from "electron";
import { appName, isMac } from "../common/vars";
import path from "path";
import { LensProxy } from "./lens-proxy";
import { WindowManager } from "./window-manager";
import { ClusterManager } from "./cluster-manager";
import { AppUpdater } from "./app-updater";
import { shellSync } from "./shell-sync";
import { getFreePort } from "./port";
import { mangleProxyEnv } from "./proxy-env";
@@ -18,7 +17,6 @@ import { registerFileProtocol } from "../common/register-protocol";
import logger from "./logger";
import { clusterStore } from "../common/cluster-store";
import { userStore } from "../common/user-store";
import { workspaceStore } from "../common/workspace-store";
import { appEventBus } from "../common/event-bus";
import { extensionLoader } from "../extensions/extension-loader";
import { extensionsStore } from "../extensions/extensions-store";
@@ -26,6 +24,14 @@ import { InstalledExtension, extensionDiscovery } from "../extensions/extension-
import type { LensExtensionId } from "../extensions/lens-extension";
import { installDeveloperTools } from "./developer-tools";
import { filesystemProvisionerStore } from "./extension-filesystem";
import { LensProtocolRouterMain } from "./protocol-handler";
import { getAppVersion, getAppVersionFromProxyServer } from "../common/utils";
import { bindBroadcastHandlers } from "../common/ipc";
import { startUpdateChecking } from "./app-updater";
import { IpcRendererNavigationEvents } from "../renderer/navigation/events";
import { CatalogPusher } from "./catalog-pusher";
import { catalogEntityRegistry } from "../common/catalog-entity-registry";
import { hotbarStore } from "../common/hotbar-store";
const workingDir = path.join(app.getPath("appData"), appName);
let proxyPort: number;
@@ -35,57 +41,82 @@ let windowManager: WindowManager;
app.setName(appName);
logger.info("📟 Setting Lens as protocol client for lens://");
if (app.setAsDefaultProtocolClient("lens")) {
logger.info("📟 succeeded ✅");
} else {
logger.info("📟 failed ❗");
}
if (!process.env.CICD) {
app.setPath("userData", workingDir);
}
if (process.env.LENS_DISABLE_GPU) {
app.disableHardwareAcceleration();
}
mangleProxyEnv();
if (app.commandLine.getSwitchValue("proxy-server") !== "") {
process.env.HTTPS_PROXY = app.commandLine.getSwitchValue("proxy-server");
}
const instanceLock = app.requestSingleInstanceLock();
if (!instanceLock) {
if (!app.requestSingleInstanceLock()) {
app.exit();
} else {
const lprm = LensProtocolRouterMain.getInstance<LensProtocolRouterMain>();
for (const arg of process.argv) {
if (arg.toLowerCase().startsWith("lens://")) {
lprm.route(arg)
.catch(error => logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl: arg }));
}
}
}
app.on("second-instance", () => {
app.on("second-instance", (event, argv) => {
const lprm = LensProtocolRouterMain.getInstance<LensProtocolRouterMain>();
for (const arg of argv) {
if (arg.toLowerCase().startsWith("lens://")) {
lprm.route(arg)
.catch(error => logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl: arg }));
}
}
windowManager?.ensureMainWindow();
});
if (process.env.LENS_DISABLE_GPU) {
app.disableHardwareAcceleration();
}
app.on("ready", async () => {
logger.info(`🚀 Starting Lens from "${workingDir}"`);
logger.info("🐚 Syncing shell environment");
await shellSync();
bindBroadcastHandlers();
powerMonitor.on("shutdown", () => {
app.exit();
});
const updater = new AppUpdater();
updater.start();
registerFileProtocol("static", __static);
await installDeveloperTools();
logger.info("💾 Loading stores");
// preload
await Promise.all([
userStore.load(),
clusterStore.load(),
workspaceStore.load(),
hotbarStore.load(),
extensionsStore.load(),
filesystemProvisionerStore.load(),
]);
// find free port
try {
logger.info("🔑 Getting free port for LensProxy server");
proxyPort = await getFreePort();
} catch (error) {
logger.error(error);
@@ -98,6 +129,7 @@ app.on("ready", async () => {
// run proxy
try {
logger.info("🔌 Starting LensProxy");
// eslint-disable-next-line unused-imports/no-unused-vars-ts
proxyServer = LensProxy.create(proxyPort, clusterManager);
} catch (error) {
@@ -106,10 +138,49 @@ app.on("ready", async () => {
app.exit();
}
// test proxy connection
try {
logger.info("🔎 Testing LensProxy connection ...");
const versionFromProxy = await getAppVersionFromProxyServer(proxyPort);
if (getAppVersion() !== versionFromProxy) {
logger.error(`Proxy server responded with invalid response`);
}
logger.info("⚡ LensProxy connection OK");
} catch (error) {
logger.error("Checking proxy server connection failed", error);
}
extensionLoader.init();
extensionDiscovery.init();
// Start the app without showing the main window when auto starting on login
// (On Windows and Linux, we get a flag. On MacOS, we get special API.)
const startHidden = process.argv.includes("--hidden") || (isMac && app.getLoginItemSettings().wasOpenedAsHidden);
logger.info("🖥️ Starting WindowManager");
windowManager = WindowManager.getInstance<WindowManager>(proxyPort);
if (!startHidden) {
windowManager.initMainWindow();
}
ipcMain.on(IpcRendererNavigationEvents.LOADED, () => {
CatalogPusher.init(catalogEntityRegistry);
startUpdateChecking();
LensProtocolRouterMain
.getInstance<LensProtocolRouterMain>()
.rendererLoaded = true;
});
extensionLoader.whenLoaded.then(() => {
LensProtocolRouterMain
.getInstance<LensProtocolRouterMain>()
.extensionsLoaded = true;
});
logger.info("🧩 Initializing extensions");
// call after windowManager to see splash earlier
try {
const extensions = await extensionDiscovery.load();
@@ -145,14 +216,35 @@ app.on("activate", (event, hasVisibleWindows) => {
}
});
// Quit app on Cmd+Q (MacOS)
/**
* This variable should is used so that `autoUpdater.installAndQuit()` works
*/
let blockQuit = true;
autoUpdater.on("before-quit-for-update", () => blockQuit = false);
app.on("will-quit", (event) => {
// Quit app on Cmd+Q (MacOS)
logger.info("APP:QUIT");
appEventBus.emit({name: "app", action: "close"});
event.preventDefault(); // prevent app's default shutdown (e.g. required for telemetry, etc.)
clusterManager?.stop(); // close cluster connections
return; // skip exit to make tray work, to quit go to app's global menu or tray's menu
if (blockQuit) {
event.preventDefault(); // prevent app's default shutdown (e.g. required for telemetry, etc.)
return; // skip exit to make tray work, to quit go to app's global menu or tray's menu
}
});
app.on("open-url", (event, rawUrl) => {
// lens:// protocol handler
event.preventDefault();
LensProtocolRouterMain
.getInstance<LensProtocolRouterMain>()
.route(rawUrl)
.catch(error => logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl }));
});
// Extensions-api runtime exports
+14 -5
View File
@@ -24,14 +24,24 @@ export class KubeconfigManager {
protected async init() {
try {
await this.contextHandler.ensurePort();
await this.createProxyKubeconfig();
this.tempFile = await this.createProxyKubeconfig();
} catch (err) {
logger.error(`Failed to created temp config for auth-proxy`, { err });
}
}
getPath() {
async getPath() {
// create proxy kubeconfig if it is removed
if (this.tempFile !== undefined && !(await fs.pathExists(this.tempFile))) {
try {
this.tempFile = await this.createProxyKubeconfig();
} catch (err) {
logger.error(`Failed to created temp config for auth-proxy`, { err });
}
}
return this.tempFile;
}
protected resolveProxyUrl() {
@@ -71,9 +81,8 @@ export class KubeconfigManager {
// write
const configYaml = dumpConfigYaml(proxyConfig);
fs.ensureDir(path.dirname(tempFile));
fs.writeFileSync(tempFile, configYaml, { mode: 0o600 });
this.tempFile = tempFile;
await fs.ensureDir(path.dirname(tempFile));
await fs.writeFile(tempFile, configYaml, { mode: 0o600 });
logger.debug(`Created temp kubeconfig "${contextName}" at "${tempFile}": \n${configYaml}`);
return tempFile;
+4 -4
View File
@@ -23,10 +23,10 @@ const kubectlMap: Map<string, string> = new Map([
["1.14", "1.14.10"],
["1.15", "1.15.11"],
["1.16", "1.16.15"],
["1.17", bundledVersion],
["1.18", "1.18.14"],
["1.19", "1.19.5"],
["1.20", "1.20.0"]
["1.17", "1.17.17"],
["1.18", bundledVersion],
["1.19", "1.19.7"],
["1.20", "1.20.2"]
]);
const packageMirrors: Map<string, string> = new Map([
["default", "https://storage.googleapis.com/kubernetes-release/release"],
+17 -6
View File
@@ -5,11 +5,11 @@ import httpProxy from "http-proxy";
import url from "url";
import * as WebSocket from "ws";
import { apiPrefix, apiKubePrefix } from "../common/vars";
import { openShell } from "./node-shell-session";
import { Router } from "./router";
import { ClusterManager } from "./cluster-manager";
import { ContextHandler } from "./context-handler";
import logger from "./logger";
import { NodeShellSession, LocalShellSession } from "./shell-session";
export class LensProxy {
protected origin: string;
@@ -28,8 +28,8 @@ export class LensProxy {
}
listen(port = this.port): this {
this.proxyServer = this.buildCustomProxy().listen(port);
logger.info(`LensProxy server has started at ${this.origin}`);
this.proxyServer = this.buildCustomProxy().listen(port, "127.0.0.1");
logger.info(`[LENS-PROXY]: Proxy server has started at ${this.origin}`);
return this;
}
@@ -120,12 +120,18 @@ export class LensProxy {
protected createProxy(): httpProxy {
const proxy = httpProxy.createProxyServer();
proxy.on("proxyRes", (proxyRes, req) => {
proxy.on("proxyRes", (proxyRes, req, res) => {
const retryCounterId = this.getRequestId(req);
if (this.retryCounters.has(retryCounterId)) {
this.retryCounters.delete(retryCounterId);
}
if (!res.headersSent && req.url) {
const url = new URL(req.url, "http://localhost");
if (url.searchParams.has("watch")) res.flushHeaders();
}
});
proxy.on("error", (error, req, res, target) => {
@@ -167,8 +173,12 @@ export class LensProxy {
return ws.on("connection", ((socket: WebSocket, req: http.IncomingMessage) => {
const cluster = this.clusterManager.getClusterForRequest(req);
const nodeParam = url.parse(req.url, true).query["node"]?.toString();
const shell = nodeParam
? new NodeShellSession(socket, cluster, nodeParam)
: new LocalShellSession(socket, cluster);
openShell(socket, cluster, nodeParam);
shell.open()
.catch(error => logger.error(`[SHELL-SESSION]: failed to open: ${error}`, { error }));
}));
}
@@ -194,7 +204,8 @@ export class LensProxy {
if (proxyTarget) {
// allow to fetch apis in "clusterId.localhost:port" from "localhost:port"
res.setHeader("Access-Control-Allow-Origin", this.origin);
// this should be safe because we have already validated cluster uuid
res.setHeader("Access-Control-Allow-Origin", "*");
return proxy.web(req, res, proxyTarget);
}
+19 -1
View File
@@ -7,9 +7,12 @@ import { preferencesURL } from "../renderer/components/+preferences/preferences.
import { whatsNewURL } from "../renderer/components/+whats-new/whats-new.route";
import { clusterSettingsURL } from "../renderer/components/+cluster-settings/cluster-settings.route";
import { extensionsURL } from "../renderer/components/+extensions/extensions.route";
import { catalogURL } from "../renderer/components/+catalog/catalog.route";
import { menuRegistry } from "../extensions/registries/menu-registry";
import logger from "./logger";
import { exitApp } from "./exit-app";
import { broadcastMessage } from "../common/ipc";
import * as packageJson from "../../package.json";
export type MenuTopId = "mac" | "file" | "edit" | "view" | "help";
@@ -25,7 +28,7 @@ export function showAbout(browserWindow: BrowserWindow) {
`Electron: ${process.versions.electron}`,
`Chrome: ${process.versions.chrome}`,
`Node: ${process.versions.node}`,
`Copyright 2020 Mirantis, Inc.`,
packageJson.copyright,
];
dialog.showMessageBoxSync(browserWindow, {
@@ -173,6 +176,21 @@ export function buildMenu(windowManager: WindowManager) {
const viewMenu: MenuItemConstructorOptions = {
label: "View",
submenu: [
{
label: "Catalog",
accelerator: "Shift+CmdOrCtrl+C",
click() {
navigate(catalogURL());
}
},
{
label: "Command Palette...",
accelerator: "Shift+CmdOrCtrl+P",
click() {
broadcastMessage("command-palette:open");
}
},
{ type: "separator" },
{
label: "Back",
accelerator: "CmdOrCtrl+[",
-154
View File
@@ -1,154 +0,0 @@
import * as WebSocket from "ws";
import * as pty from "node-pty";
import { ShellSession } from "./shell-session";
import { v4 as uuid } from "uuid";
import * as k8s from "@kubernetes/client-node";
import { KubeConfig } from "@kubernetes/client-node";
import { Cluster } from "./cluster";
import logger from "./logger";
import { appEventBus } from "../common/event-bus";
export class NodeShellSession extends ShellSession {
protected nodeName: string;
protected podId: string;
protected kc: KubeConfig;
constructor(socket: WebSocket, cluster: Cluster, nodeName: string) {
super(socket, cluster);
this.nodeName = nodeName;
this.podId = `node-shell-${uuid()}`;
this.kc = cluster.getProxyKubeconfig();
}
public async open() {
const shell = await this.kubectl.getPath();
let args = [];
if (this.createNodeShellPod(this.podId, this.nodeName)) {
await this.waitForRunningPod(this.podId).catch(() => {
this.exit(1001);
});
}
args = ["exec", "-i", "-t", "-n", "kube-system", this.podId, "--", "sh", "-c", "((clear && bash) || (clear && ash) || (clear && sh))"];
const shellEnv = await this.getCachedShellEnv();
this.shellProcess = pty.spawn(shell, args, {
cols: 80,
cwd: this.cwd() || shellEnv["HOME"],
env: shellEnv,
name: "xterm-256color",
rows: 30,
});
this.running = true;
this.pipeStdout();
this.pipeStdin();
this.closeWebsocketOnProcessExit();
this.exitProcessOnWebsocketClose();
appEventBus.emit({name: "node-shell", action: "open"});
}
protected exit(code = 1000) {
if (this.podId) {
this.deleteNodeShellPod();
}
super.exit(code);
}
protected async createNodeShellPod(podId: string, nodeName: string) {
const kc = this.getKubeConfig();
const k8sApi = kc.makeApiClient(k8s.CoreV1Api);
const pod = {
metadata: {
name: podId,
namespace: "kube-system"
},
spec: {
restartPolicy: "Never",
terminationGracePeriodSeconds: 0,
hostPID: true,
hostIPC: true,
hostNetwork: true,
tolerations: [{
operator: "Exists"
}],
containers: [{
name: "shell",
image: "docker.io/alpine:3.12",
securityContext: {
privileged: true,
},
command: ["nsenter"],
args: ["-t", "1", "-m", "-u", "-i", "-n", "sleep", "14000"]
}],
nodeSelector: {
"kubernetes.io/hostname": nodeName
}
}
} as k8s.V1Pod;
await k8sApi.createNamespacedPod("kube-system", pod).catch((error) => {
logger.error(error);
return false;
});
return true;
}
protected getKubeConfig() {
if (this.kc) {
return this.kc;
}
this.kc = new k8s.KubeConfig();
this.kc.loadFromFile(this.kubeconfigPath);
return this.kc;
}
protected waitForRunningPod(podId: string) {
return new Promise<boolean>(async (resolve, reject) => {
const kc = this.getKubeConfig();
const watch = new k8s.Watch(kc);
const req = await watch.watch(`/api/v1/namespaces/kube-system/pods`, {},
// callback is called for each received object.
(type, obj) => {
if (obj.metadata.name == podId && obj.status.phase === "Running") {
resolve(true);
}
},
// done callback is called if the watch terminates normally
(err) => {
logger.error(err);
reject(false);
}
);
setTimeout(() => {
req.abort();
reject(false);
}, 120 * 1000);
});
}
protected deleteNodeShellPod() {
const kc = this.getKubeConfig();
const k8sApi = kc.makeApiClient(k8s.CoreV1Api);
k8sApi.deleteNamespacedPod(this.podId, "kube-system");
}
}
export async function openShell(socket: WebSocket, cluster: Cluster, nodeName?: string): Promise<ShellSession> {
let shell: ShellSession;
if (nodeName) {
shell = new NodeShellSession(socket, cluster, nodeName);
} else {
shell = new ShellSession(socket, cluster);
}
shell.open();
return shell;
}
@@ -0,0 +1,259 @@
import { LensProtocolRouterMain } from "../router";
import { noop } from "../../../common/utils";
import { extensionsStore } from "../../../extensions/extensions-store";
import { extensionLoader } from "../../../extensions/extension-loader";
import * as uuid from "uuid";
import { LensMainExtension } from "../../../extensions/core-api";
import { broadcastMessage } from "../../../common/ipc";
import { ProtocolHandlerExtension, ProtocolHandlerInternal } from "../../../common/protocol-handler";
jest.mock("../../../common/ipc");
function throwIfDefined(val: any): void {
if (val != null) {
throw val;
}
}
describe("protocol router tests", () => {
let lpr: LensProtocolRouterMain;
beforeEach(() => {
jest.clearAllMocks();
(extensionsStore as any).state.clear();
(extensionLoader as any).instances.clear();
LensProtocolRouterMain.resetInstance();
lpr = LensProtocolRouterMain.getInstance<LensProtocolRouterMain>();
lpr.extensionsLoaded = true;
lpr.rendererLoaded = true;
});
it("should throw on non-lens URLS", async () => {
try {
expect(await lpr.route("https://google.ca")).toBeUndefined();
} catch (error) {
expect(error).toBeInstanceOf(Error);
}
});
it("should throw when host not internal or extension", async () => {
try {
expect(await lpr.route("lens://foobar")).toBeUndefined();
} catch (error) {
expect(error).toBeInstanceOf(Error);
}
});
it("should not throw when has valid host", async () => {
const extId = uuid.v4();
const ext = new LensMainExtension({
id: extId,
manifestPath: "/foo/bar",
manifest: {
name: "@mirantis/minikube",
version: "0.1.1",
},
isBundled: false,
isEnabled: true,
absolutePath: "/foo/bar",
});
ext.protocolHandlers.push({
pathSchema: "/",
handler: noop,
});
(extensionLoader as any).instances.set(extId, ext);
(extensionsStore as any).state.set(extId, { enabled: true, name: "@mirantis/minikube" });
lpr.addInternalHandler("/", noop);
try {
expect(await lpr.route("lens://app")).toBeUndefined();
} catch (error) {
expect(throwIfDefined(error)).not.toThrow();
}
try {
expect(await lpr.route("lens://extension/@mirantis/minikube")).toBeUndefined();
} catch (error) {
expect(throwIfDefined(error)).not.toThrow();
}
expect(broadcastMessage).toHaveBeenNthCalledWith(1, ProtocolHandlerInternal, "lens://app");
expect(broadcastMessage).toHaveBeenNthCalledWith(2, ProtocolHandlerExtension, "lens://extension/@mirantis/minikube");
});
it("should call handler if matches", async () => {
let called = false;
lpr.addInternalHandler("/page", () => { called = true; });
try {
expect(await lpr.route("lens://app/page")).toBeUndefined();
} catch (error) {
expect(throwIfDefined(error)).not.toThrow();
}
expect(called).toBe(true);
expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page");
});
it("should call most exact handler", async () => {
let called: any = 0;
lpr.addInternalHandler("/page", () => { called = 1; });
lpr.addInternalHandler("/page/:id", params => { called = params.pathname.id; });
try {
expect(await lpr.route("lens://app/page/foo")).toBeUndefined();
} catch (error) {
expect(throwIfDefined(error)).not.toThrow();
}
expect(called).toBe("foo");
expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page/foo");
});
it("should call most exact handler for an extension", async () => {
let called: any = 0;
const extId = uuid.v4();
const ext = new LensMainExtension({
id: extId,
manifestPath: "/foo/bar",
manifest: {
name: "@foobar/icecream",
version: "0.1.1",
},
isBundled: false,
isEnabled: true,
absolutePath: "/foo/bar",
});
ext.protocolHandlers
.push({
pathSchema: "/page",
handler: () => { called = 1; },
}, {
pathSchema: "/page/:id",
handler: params => { called = params.pathname.id; },
});
(extensionLoader as any).instances.set(extId, ext);
(extensionsStore as any).state.set(extId, { enabled: true, name: "@foobar/icecream" });
try {
expect(await lpr.route("lens://extension/@foobar/icecream/page/foob")).toBeUndefined();
} catch (error) {
expect(throwIfDefined(error)).not.toThrow();
}
expect(called).toBe("foob");
expect(broadcastMessage).toBeCalledWith(ProtocolHandlerExtension, "lens://extension/@foobar/icecream/page/foob");
});
it("should work with non-org extensions", async () => {
let called: any = 0;
{
const extId = uuid.v4();
const ext = new LensMainExtension({
id: extId,
manifestPath: "/foo/bar",
manifest: {
name: "@foobar/icecream",
version: "0.1.1",
},
isBundled: false,
isEnabled: true,
absolutePath: "/foo/bar",
});
ext.protocolHandlers
.push({
pathSchema: "/page/:id",
handler: params => { called = params.pathname.id; },
});
(extensionLoader as any).instances.set(extId, ext);
(extensionsStore as any).state.set(extId, { enabled: true, name: "@foobar/icecream" });
}
{
const extId = uuid.v4();
const ext = new LensMainExtension({
id: extId,
manifestPath: "/foo/bar",
manifest: {
name: "icecream",
version: "0.1.1",
},
isBundled: false,
isEnabled: true,
absolutePath: "/foo/bar",
});
ext.protocolHandlers
.push({
pathSchema: "/page",
handler: () => { called = 1; },
});
(extensionLoader as any).instances.set(extId, ext);
(extensionsStore as any).state.set(extId, { enabled: true, name: "icecream" });
}
(extensionsStore as any).state.set("@foobar/icecream", { enabled: true, name: "@foobar/icecream" });
(extensionsStore as any).state.set("icecream", { enabled: true, name: "icecream" });
try {
expect(await lpr.route("lens://extension/icecream/page")).toBeUndefined();
} catch (error) {
expect(throwIfDefined(error)).not.toThrow();
}
expect(called).toBe(1);
expect(broadcastMessage).toBeCalledWith(ProtocolHandlerExtension, "lens://extension/icecream/page");
});
it("should throw if urlSchema is invalid", () => {
expect(() => lpr.addInternalHandler("/:@", noop)).toThrowError();
});
it("should call most exact handler with 3 found handlers", async () => {
let called: any = 0;
lpr.addInternalHandler("/", () => { called = 2; });
lpr.addInternalHandler("/page", () => { called = 1; });
lpr.addInternalHandler("/page/foo", () => { called = 3; });
lpr.addInternalHandler("/page/bar", () => { called = 4; });
try {
expect(await lpr.route("lens://app/page/foo/bar/bat")).toBeUndefined();
} catch (error) {
expect(throwIfDefined(error)).not.toThrow();
}
expect(called).toBe(3);
expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page/foo/bar/bat");
});
it("should call most exact handler with 2 found handlers", async () => {
let called: any = 0;
lpr.addInternalHandler("/", () => { called = 2; });
lpr.addInternalHandler("/page", () => { called = 1; });
lpr.addInternalHandler("/page/bar", () => { called = 4; });
try {
expect(await lpr.route("lens://app/page/foo/bar/bat")).toBeUndefined();
} catch (error) {
expect(throwIfDefined(error)).not.toThrow();
}
expect(called).toBe(1);
expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page/foo/bar/bat");
});
});
+1
View File
@@ -0,0 +1 @@
export * from "./router";
+112
View File
@@ -0,0 +1,112 @@
import logger from "../logger";
import * as proto from "../../common/protocol-handler";
import Url from "url-parse";
import { LensExtension } from "../../extensions/lens-extension";
import { broadcastMessage } from "../../common/ipc";
import { observable, when } from "mobx";
export interface FallbackHandler {
(name: string): Promise<boolean>;
}
export class LensProtocolRouterMain extends proto.LensProtocolRouter {
private missingExtensionHandlers: FallbackHandler[] = [];
@observable rendererLoaded = false;
@observable extensionsLoaded = false;
/**
* Find the most specific registered handler, if it exists, and invoke it.
*
* This will send an IPC message to the renderer router to do the same
* in the renderer.
*/
public async route(rawUrl: string): Promise<void> {
try {
const url = new Url(rawUrl, true);
if (url.protocol.toLowerCase() !== "lens:") {
throw new proto.RoutingError(proto.RoutingErrorType.INVALID_PROTOCOL, url);
}
logger.info(`${proto.LensProtocolRouter.LoggingPrefix}: routing ${url.toString()}`);
switch (url.host) {
case "app":
return this._routeToInternal(url);
case "extension":
await when(() => this.extensionsLoaded);
return this._routeToExtension(url);
default:
throw new proto.RoutingError(proto.RoutingErrorType.INVALID_HOST, url);
}
} catch (error) {
if (error instanceof proto.RoutingError) {
logger.error(`${proto.LensProtocolRouter.LoggingPrefix}: ${error}`, { url: error.url });
} else {
logger.error(`${proto.LensProtocolRouter.LoggingPrefix}: ${error}`, { rawUrl });
}
}
}
protected async _executeMissingExtensionHandlers(extensionName: string): Promise<boolean> {
for (const handler of this.missingExtensionHandlers) {
if (await handler(extensionName)) {
return true;
}
}
return false;
}
protected async _findMatchingExtensionByName(url: Url): Promise<LensExtension | string> {
const firstAttempt = await super._findMatchingExtensionByName(url);
if (typeof firstAttempt !== "string") {
return firstAttempt;
}
if (await this._executeMissingExtensionHandlers(firstAttempt)) {
return super._findMatchingExtensionByName(url);
}
return "";
}
protected async _routeToInternal(url: Url): Promise<void> {
const rawUrl = url.toString(); // for sending to renderer
super._routeToInternal(url);
await when(() => this.rendererLoaded);
return broadcastMessage(proto.ProtocolHandlerInternal, rawUrl);
}
protected async _routeToExtension(url: Url): Promise<void> {
const rawUrl = url.toString(); // for sending to renderer
/**
* This needs to be done first, so that the missing extension handlers can
* be called before notifying the renderer.
*
* Note: this needs to clone the url because _routeToExtension modifies its
* argument.
*/
await super._routeToExtension(new Url(url.toString(), true));
await when(() => this.rendererLoaded);
return broadcastMessage(proto.ProtocolHandlerExtension, rawUrl);
}
/**
* Add a function to the list which will be sequentially called if an extension
* is not found while routing to the extensions
* @param handler A function that tries to find an extension
*/
public addMissingExtensionHandler(handler: FallbackHandler): void {
this.missingExtensionHandlers.push(handler);
}
}
+4 -2
View File
@@ -23,12 +23,13 @@ export class ResourceApplier {
protected async kubectlApply(content: string): Promise<string> {
const { kubeCtl } = this.cluster;
const kubectlPath = await kubeCtl.getPath();
const proxyKubeconfigPath = await this.cluster.getProxyKubeconfigPath();
return new Promise<string>((resolve, reject) => {
const fileName = tempy.file({ name: "resource.yaml" });
fs.writeFileSync(fileName, content);
const cmd = `"${kubectlPath}" apply --kubeconfig "${this.cluster.getProxyKubeconfigPath()}" -o json -f "${fileName}"`;
const cmd = `"${kubectlPath}" apply --kubeconfig "${proxyKubeconfigPath}" -o json -f "${fileName}"`;
logger.debug(`shooting manifests with: ${cmd}`);
const execEnv: NodeJS.ProcessEnv = Object.assign({}, process.env);
@@ -54,6 +55,7 @@ export class ResourceApplier {
public async kubectlApplyAll(resources: string[]): Promise<string> {
const { kubeCtl } = this.cluster;
const kubectlPath = await kubeCtl.getPath();
const proxyKubeconfigPath = await this.cluster.getProxyKubeconfigPath();
return new Promise((resolve, reject) => {
const tmpDir = tempy.directory();
@@ -62,7 +64,7 @@ export class ResourceApplier {
resources.forEach((resource, index) => {
fs.writeFileSync(path.join(tmpDir, `${index}.yaml`), resource);
});
const cmd = `"${kubectlPath}" apply --kubeconfig "${this.cluster.getProxyKubeconfigPath()}" -o json -f "${tmpDir}"`;
const cmd = `"${kubectlPath}" apply --kubeconfig "${proxyKubeconfigPath}" -o json -f "${tmpDir}"`;
console.log("shooting manifests with:", cmd);
exec(cmd, (error, stdout, stderr) => {
+17 -5
View File
@@ -5,7 +5,7 @@ import path from "path";
import { readFile } from "fs-extra";
import { Cluster } from "./cluster";
import { apiPrefix, appName, publicPath, isDevelopment, webpackDevServerPort } from "../common/vars";
import { helmRoute, kubeconfigRoute, metricsRoute, portForwardRoute, resourceApplierRoute, watchRoute } from "./routes";
import { helmRoute, kubeconfigRoute, metricsRoute, portForwardRoute, resourceApplierRoute, versionRoute } from "./routes";
import logger from "./logger";
export interface RouterRequestOpts {
@@ -40,10 +40,16 @@ export interface LensApiRequest<P = any> {
export class Router {
protected router: any;
protected staticRootPath: string;
public constructor() {
this.router = new Call.Router();
this.addRoutes();
this.staticRootPath = this.resolveStaticRootPath();
}
protected resolveStaticRootPath() {
return path.resolve(__static);
}
public async route(cluster: Cluster, req: http.IncomingMessage, res: http.ServerResponse): Promise<boolean> {
@@ -102,7 +108,15 @@ export class Router {
}
async handleStaticFile(filePath: string, res: http.ServerResponse, req: http.IncomingMessage, retryCount = 0) {
const asset = path.join(__static, filePath);
const asset = path.join(this.staticRootPath, filePath);
const normalizedFilePath = path.resolve(asset);
if (!normalizedFilePath.startsWith(this.staticRootPath)) {
res.statusCode = 404;
res.end();
return;
}
try {
const filename = path.basename(req.url);
@@ -143,11 +157,9 @@ export class Router {
this.handleStaticFile(params.path, response, req);
});
this.router.add({ method: "get", path: "/version"}, versionRoute.getVersion.bind(versionRoute));
this.router.add({ method: "get", path: `${apiPrefix}/kubeconfig/service-account/{namespace}/{account}` }, kubeconfigRoute.routeServiceAccountRoute.bind(kubeconfigRoute));
// Watch API
this.router.add({ method: "get", path: `${apiPrefix}/watch` }, watchRoute.routeWatch.bind(watchRoute));
// Metrics API
this.router.add({ method: "post", path: `${apiPrefix}/metrics` }, metricsRoute.routeMetrics.bind(metricsRoute));
+2 -2
View File
@@ -70,7 +70,7 @@ class HelmApiRoute extends LensApi {
this.respondJson(response, result);
} catch (error) {
logger.debug(error);
this.respondText(response, error);
this.respondText(response, error, 422);
}
}
@@ -83,7 +83,7 @@ class HelmApiRoute extends LensApi {
this.respondJson(response, result);
} catch(error) {
logger.debug(error);
this.respondText(response, error);
this.respondText(response, error, 422);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
export * from "./kubeconfig-route";
export * from "./metrics-route";
export * from "./port-forward-route";
export * from "./watch-route";
export * from "./helm-route";
export * from "./resource-applier-route";
export * from "./version-route";
+1 -1
View File
@@ -44,7 +44,7 @@ class KubeconfigRoute extends LensApi {
public async routeServiceAccountRoute(request: LensApiRequest) {
const { params, response, cluster} = request;
const client = cluster.getProxyKubeconfig().makeApiClient(CoreV1Api);
const client = (await cluster.getProxyKubeconfig()).makeApiClient(CoreV1Api);
const secretList = await client.listNamespacedSecret(params.namespace);
const secret = secretList.body.items.find(secret => {
const { annotations } = secret.metadata;
+1 -1
View File
@@ -92,7 +92,7 @@ class PortForwardRoute extends LensApi {
namespace,
name: resourceName,
port,
kubeConfig: cluster.getProxyKubeconfigPath()
kubeConfig: await cluster.getProxyKubeconfigPath()
});
const started = await portForward.start();
Loaded 100 of 560 files, more files were not shown because too many files have changed in this diff. Show more