mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Merge branch 'master' into improve-metrics-queries
Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
@@ -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());
|
||||
@@ -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());
|
||||
@@ -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();
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -10,4 +10,4 @@ export class ExecValidationNotFoundError extends Error {
|
||||
this.name = this.constructor.name;
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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];
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./error";
|
||||
export * from "./router";
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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("");
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
@@ -26,4 +26,4 @@ class Singleton {
|
||||
}
|
||||
|
||||
export { Singleton };
|
||||
export default Singleton;
|
||||
export default Singleton;
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>();
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -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";
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "../../common/catalog-entity";
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./registrations";
|
||||
export * from "./registrations";
|
||||
export * from "./catalog";
|
||||
@@ -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";
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,4 @@ import { themeStore } from "../../renderer/theme.store";
|
||||
|
||||
export function getActiveTheme() {
|
||||
return themeStore.activeTheme;
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
@@ -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,3 +4,7 @@ fetchMock.enableMocks();
|
||||
|
||||
// Mock __non_webpack_require__ for tests
|
||||
globalThis.__non_webpack_require__ = jest.fn();
|
||||
|
||||
process.on("unhandledRejection", (err) => {
|
||||
fail(err);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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) });
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 {};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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: []})};
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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+[",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./router";
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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));
|
||||
|
||||
|
||||
@@ -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,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";
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in new issue
Block a user