Restructure to monorepo (#6907)

* wip: restructure to monorepo

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* refactor create-release-pr to a package

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* build fixes

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* github workflow fixes

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* fix typo

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* add webpack-env types to core

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* fix github workflows

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* refactor/fix integration tests

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* lint fix

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* yarn run dev

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* eslint settings for vscode

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* move templates to right package

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* open-lens build fixes

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* integration test fix

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* fix nx task dependencies

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* use bash shell for unit tests in test workflow

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* fix test:unit for windows

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* fix win-ca webpack error in open-lens

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* fix win-ca webpack error in open-lens

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* fix build:app on windows

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* remove ELECTRON_BUILDER_EXTRA_ARGS

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* sync src/ from master

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* remove Makefile from core

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>
This commit is contained in:
Jari Kolehmainen authored and GitHub committed 2023-01-24 10:46:26 -08:00
1 parent 0066ca4ea2
commit 2657df2293
3321 files changed
+28699 -11447

No files matched your search

@@ -0,0 +1,103 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import broadcastMessageInjectable from "../../common/ipc/broadcast-message.injectable";
import type { Cluster } from "../../common/cluster/cluster";
import { Kubectl } from "../kubectl/kubectl";
import { getDiForUnitTesting } from "../getDiForUnitTesting";
import type { CreateCluster } from "../../common/cluster/create-cluster-injection-token";
import { createClusterInjectionToken } from "../../common/cluster/create-cluster-injection-token";
import authorizationReviewInjectable from "../../common/cluster/authorization-review.injectable";
import requestNamespaceListPermissionsForInjectable from "../../common/cluster/request-namespace-list-permissions.injectable";
import listNamespacesInjectable from "../../common/cluster/list-namespaces.injectable";
import createContextHandlerInjectable from "../context-handler/create-context-handler.injectable";
import type { ClusterContextHandler } from "../context-handler/context-handler";
import { parse } from "url";
import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
import directoryForTempInjectable from "../../common/app-paths/directory-for-temp/directory-for-temp.injectable";
import normalizedPlatformInjectable from "../../common/vars/normalized-platform.injectable";
import kubectlBinaryNameInjectable from "../kubectl/binary-name.injectable";
import kubectlDownloadingNormalizedArchInjectable from "../kubectl/normalized-arch.injectable";
import pathExistsSyncInjectable from "../../common/fs/path-exists-sync.injectable";
import pathExistsInjectable from "../../common/fs/path-exists.injectable";
import readJsonSyncInjectable from "../../common/fs/read-json-sync.injectable";
import writeJsonSyncInjectable from "../../common/fs/write-json-sync.injectable";
describe("create clusters", () => {
let cluster: Cluster;
let createCluster: CreateCluster;
beforeEach(() => {
jest.clearAllMocks();
const di = getDiForUnitTesting({ doGeneralOverrides: true });
const clusterServerUrl = "https://192.168.64.3:8443";
di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data");
di.override(directoryForTempInjectable, () => "some-directory-for-temp");
di.override(kubectlBinaryNameInjectable, () => "kubectl");
di.override(kubectlDownloadingNormalizedArchInjectable, () => "amd64");
di.override(normalizedPlatformInjectable, () => "darwin");
di.override(broadcastMessageInjectable, () => async () => {});
di.override(authorizationReviewInjectable, () => () => () => Promise.resolve(true));
di.override(requestNamespaceListPermissionsForInjectable, () => () => async () => () => true);
di.override(listNamespacesInjectable, () => () => () => Promise.resolve([ "default" ]));
di.override(createContextHandlerInjectable, () => (cluster) => ({
restartServer: jest.fn(),
stopServer: jest.fn(),
clusterUrl: parse(cluster.apiUrl),
getApiTarget: jest.fn(),
getPrometheusDetails: jest.fn(),
resolveAuthProxyCa: jest.fn(),
resolveAuthProxyUrl: jest.fn(),
setupPrometheus: jest.fn(),
ensureServer: jest.fn(),
} as ClusterContextHandler));
di.override(pathExistsInjectable, () => () => { throw new Error("tried call pathExists without override"); });
di.override(pathExistsSyncInjectable, () => () => { throw new Error("tried call pathExistsSync without override"); });
di.override(readJsonSyncInjectable, () => () => { throw new Error("tried call readJsonSync without override"); });
di.override(writeJsonSyncInjectable, () => () => { throw new Error("tried call writeJsonSync without override"); });
createCluster = di.inject(createClusterInjectionToken);
jest.spyOn(Kubectl.prototype, "ensureKubectl").mockReturnValue(Promise.resolve(true));
cluster = createCluster({
id: "foo",
contextName: "minikube",
kubeConfigPath: "minikube-config.yml",
}, {
clusterServerUrl,
});
});
afterEach(() => {
cluster.disconnect();
});
it("should be able to create a cluster from a cluster model and apiURL should be decoded", () => {
expect(cluster.apiUrl).toBe("https://192.168.64.3:8443");
});
it("reconnect should not throw if contextHandler is missing", () => {
expect(() => cluster.reconnect()).not.toThrowError();
});
it("disconnect should not throw if contextHandler is missing", () => {
expect(() => cluster.disconnect()).not.toThrowError();
});
it("activating cluster should try to connect to cluster and do a refresh", async () => {
jest.spyOn(cluster, "reconnect");
jest.spyOn(cluster, "refreshConnectionStatus");
await cluster.activate();
expect(cluster.reconnect).toBeCalled();
expect(cluster.refreshConnectionStatus).toBeCalled();
cluster.disconnect();
jest.resetAllMocks();
});
});
@@ -0,0 +1,191 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { ClusterContextHandler } from "../context-handler/context-handler";
import { getDiForUnitTesting } from "../getDiForUnitTesting";
import createContextHandlerInjectable from "../context-handler/create-context-handler.injectable";
import type { Cluster } from "../../common/cluster/cluster";
import createKubeAuthProxyInjectable from "../kube-auth-proxy/create-kube-auth-proxy.injectable";
import type { DiContainer } from "@ogre-tools/injectable";
import { getInjectable } from "@ogre-tools/injectable";
import type { PrometheusProvider } from "../prometheus/provider";
import { prometheusProviderInjectionToken } from "../prometheus/provider";
import { runInAction } from "mobx";
enum ServiceResult {
Success,
Failure,
}
const createTestPrometheusProvider = (kind: string, alwaysFail: ServiceResult): PrometheusProvider => ({
kind,
name: "TestProvider1",
isConfigurable: false,
getQuery: () => {
throw new Error("getQuery is not implemented.");
},
getPrometheusService: async () => {
switch (alwaysFail) {
case ServiceResult.Success:
return {
kind,
namespace: "default",
port: 7000,
service: "",
};
case ServiceResult.Failure:
throw new Error("does fail");
}
},
});
const clusterStub = {
getProxyKubeconfig: () => ({
makeApiClient: (): void => undefined,
}),
apiUrl: "http://localhost:81",
} as unknown as Cluster;
describe("ContextHandler", () => {
let createContextHandler: (cluster: Cluster) => ClusterContextHandler;
let di: DiContainer;
beforeEach(() => {
di = getDiForUnitTesting({ doGeneralOverrides: true });
di.override(createKubeAuthProxyInjectable, () => ({} as any));
createContextHandler = di.inject(createContextHandlerInjectable);
});
describe("getPrometheusService", () => {
it.each([
[0],
[1],
[2],
[3],
])("should throw after %d failure(s)", async (failures) => {
runInAction(() => {
for (let i = 0; i < failures; i += 1) {
di.register(getInjectable({
id: `test-prometheus-provider-failure-${i}`,
injectionToken: prometheusProviderInjectionToken,
instantiate: () => createTestPrometheusProvider(`id_failure_${i}`, ServiceResult.Failure),
}));
}
});
expect(() => createContextHandler(clusterStub).getPrometheusDetails()).rejects.toThrowError();
});
it.each([
[1, 0],
[1, 1],
[1, 2],
[1, 3],
[2, 0],
[2, 1],
[2, 2],
[2, 3],
])("should pick the first provider of %d success(es) after %d failure(s)", async (successes, failures) => {
runInAction(() => {
for (let i = 0; i < failures; i += 1) {
di.register(getInjectable({
id: `test-prometheus-provider-failure-${i}`,
injectionToken: prometheusProviderInjectionToken,
instantiate: () => createTestPrometheusProvider(`id_failure_${i}`, ServiceResult.Failure),
}));
}
for (let i = 0; i < successes; i += 1) {
di.register(getInjectable({
id: `test-prometheus-provider-success-${i}`,
injectionToken: prometheusProviderInjectionToken,
instantiate: () => createTestPrometheusProvider(`id_success_${i}`, ServiceResult.Success),
}));
}
});
const details = await createContextHandler(clusterStub).getPrometheusDetails();
expect(details.provider.kind === `id_failure_${failures}`);
});
it.each([
[1, 0],
[1, 1],
[1, 2],
[1, 3],
[2, 0],
[2, 1],
[2, 2],
[2, 3],
])("should pick the first provider of %d success(es) before %d failure(s)", async (successes, failures) => {
runInAction(() => {
for (let i = 0; i < failures; i += 1) {
di.register(getInjectable({
id: `test-prometheus-provider-failure-${i}`,
injectionToken: prometheusProviderInjectionToken,
instantiate: () => createTestPrometheusProvider(`id_failure_${i}`, ServiceResult.Failure),
}));
}
for (let i = 0; i < successes; i += 1) {
di.register(getInjectable({
id: `test-prometheus-provider-success-${i}`,
injectionToken: prometheusProviderInjectionToken,
instantiate: () => createTestPrometheusProvider(`id_success_${i}`, ServiceResult.Success),
}));
}
});
const details = await createContextHandler(clusterStub).getPrometheusDetails();
expect(details.provider.kind === "id_failure_0");
});
it.each([
[1, 0],
[1, 1],
[1, 2],
[1, 3],
[2, 0],
[2, 1],
[2, 2],
[2, 3],
])("should pick the first provider of %d success(es) between %d failure(s)", async (successes, failures) => {
const beforeSuccesses = Math.floor(successes / 2);
runInAction(() => {
for (let i = 0; i < beforeSuccesses; i += 1) {
di.register(getInjectable({
id: `test-prometheus-provider-success-${i}`,
injectionToken: prometheusProviderInjectionToken,
instantiate: () => createTestPrometheusProvider(`id_success_${i}`, ServiceResult.Success),
}));
}
for (let i = 0; i < failures; i += 1) {
di.register(getInjectable({
id: `test-prometheus-provider-failure-${i}`,
injectionToken: prometheusProviderInjectionToken,
instantiate: () => createTestPrometheusProvider(`id_failure_${i}`, ServiceResult.Failure),
}));
}
for (let i = beforeSuccesses; i < successes; i += 1) {
di.register(getInjectable({
id: `test-prometheus-provider-success-${i}`,
injectionToken: prometheusProviderInjectionToken,
instantiate: () => createTestPrometheusProvider(`id_success_${i}`, ServiceResult.Success),
}));
}
});
const details = await createContextHandler(clusterStub).getPrometheusDetails();
expect(details.provider.kind === "id_success_0");
});
});
});
@@ -0,0 +1,233 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import waitUntilPortIsUsedInjectable from "../kube-auth-proxy/wait-until-port-is-used/wait-until-port-is-used.injectable";
import type { Cluster } from "../../common/cluster/cluster";
import type { KubeAuthProxy } from "../kube-auth-proxy/kube-auth-proxy";
import type { ChildProcess } from "child_process";
import { Kubectl } from "../kubectl/kubectl";
import type { DeepMockProxy } from "jest-mock-extended";
import { mockDeep, mock } from "jest-mock-extended";
import type { Readable } from "stream";
import { EventEmitter } from "stream";
import { getDiForUnitTesting } from "../getDiForUnitTesting";
import createKubeAuthProxyInjectable from "../kube-auth-proxy/create-kube-auth-proxy.injectable";
import type { CreateCluster } from "../../common/cluster/create-cluster-injection-token";
import { createClusterInjectionToken } from "../../common/cluster/create-cluster-injection-token";
import spawnInjectable from "../child-process/spawn.injectable";
import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
import directoryForTempInjectable from "../../common/app-paths/directory-for-temp/directory-for-temp.injectable";
import normalizedPlatformInjectable from "../../common/vars/normalized-platform.injectable";
import kubectlBinaryNameInjectable from "../kubectl/binary-name.injectable";
import kubectlDownloadingNormalizedArchInjectable from "../kubectl/normalized-arch.injectable";
import broadcastMessageInjectable from "../../common/ipc/broadcast-message.injectable";
import writeJsonSyncInjectable from "../../common/fs/write-json-sync.injectable";
import ensureDirInjectable from "../../common/fs/ensure-dir.injectable";
import type { GetBasenameOfPath } from "../../common/path/get-basename.injectable";
import getBasenameOfPathInjectable from "../../common/path/get-basename.injectable";
const clusterServerUrl = "https://192.168.64.3:8443";
describe("kube auth proxy tests", () => {
let createCluster: CreateCluster;
let createKubeAuthProxy: (cluster: Cluster, environmentVariables: NodeJS.ProcessEnv) => KubeAuthProxy;
let spawnMock: jest.Mock;
let waitUntilPortIsUsedMock: jest.Mock;
let broadcastMessageMock: jest.Mock;
let getBasenameOfPath: GetBasenameOfPath;
beforeEach(async () => {
const di = getDiForUnitTesting({ doGeneralOverrides: true });
di.override(directoryForUserDataInjectable, () => "/some-directory-for-user-data");
di.override(directoryForTempInjectable, () => "/some-directory-for-temp");
const writeJsonSync = di.inject(writeJsonSyncInjectable);
const ensureDir = di.inject(ensureDirInjectable);
getBasenameOfPath = di.inject(getBasenameOfPathInjectable);
writeJsonSync("/minikube-config.yml", {
apiVersion: "v1",
clusters: [{
name: "minikube",
cluster: {
server: clusterServerUrl,
},
}],
"current-context": "minikube",
contexts: [{
context: {
cluster: "minikube",
user: "minikube",
},
name: "minikube",
}],
users: [{
name: "minikube",
}],
kind: "Config",
preferences: {},
});
await ensureDir("/tmp");
spawnMock = jest.fn();
di.override(spawnInjectable, () => spawnMock);
waitUntilPortIsUsedMock = jest.fn();
di.override(waitUntilPortIsUsedInjectable, () => waitUntilPortIsUsedMock);
broadcastMessageMock = jest.fn();
di.override(broadcastMessageInjectable, () => broadcastMessageMock);
di.override(kubectlBinaryNameInjectable, () => "kubectl");
di.override(kubectlDownloadingNormalizedArchInjectable, () => "amd64");
di.override(normalizedPlatformInjectable, () => "darwin");
createCluster = di.inject(createClusterInjectionToken);
createKubeAuthProxy = di.inject(createKubeAuthProxyInjectable);
});
it("calling exit multiple times shouldn't throw", async () => {
const cluster = createCluster({
id: "foobar",
kubeConfigPath: "minikube-config.yml",
contextName: "minikube",
}, {
clusterServerUrl,
});
const kap = createKubeAuthProxy(cluster, {});
kap.exit();
kap.exit();
kap.exit();
});
describe("spawn tests", () => {
let mockedCP: DeepMockProxy<ChildProcess>;
let listeners: EventEmitter;
let proxy: KubeAuthProxy;
beforeEach(async () => {
mockedCP = mockDeep<ChildProcess>();
listeners = new EventEmitter();
const stderr = mockedCP.stderr = mock<Readable>();
const stdout = mockedCP.stdout = mock<Readable>();
jest.spyOn(Kubectl.prototype, "checkBinary").mockReturnValueOnce(Promise.resolve(true));
jest.spyOn(Kubectl.prototype, "ensureKubectl").mockReturnValueOnce(Promise.resolve(false));
mockedCP.on.mockImplementation((event: string | symbol, listener: (message: any, sendHandle: any) => void): ChildProcess => {
listeners.on(event, listener);
return mockedCP;
});
mockedCP.stderr.on.mockImplementation((event: string | symbol, listener: (message: any, sendHandle: any) => void): Readable => {
listeners.on(`stderr/${String(event)}`, listener);
return stderr;
});
mockedCP.stderr.off.mockImplementation((event: string | symbol, listener: (message: any, sendHandle: any) => void): Readable => {
listeners.off(`stderr/${String(event)}`, listener);
return stderr;
});
mockedCP.stderr.removeListener.mockImplementation((event: string | symbol, listener: (message: any, sendHandle: any) => void): Readable => {
listeners.off(`stderr/${String(event)}`, listener);
return stderr;
});
mockedCP.stderr.once.mockImplementation((event: string | symbol, listener: (message: any, sendHandle: any) => void): Readable => {
listeners.once(`stderr/${String(event)}`, listener);
return stderr;
});
mockedCP.stderr.removeAllListeners.mockImplementation((event?: string | symbol): Readable => {
listeners.removeAllListeners(event ?? `stderr/${String(event)}`);
return stderr;
});
mockedCP.stdout.on.mockImplementation((event: string | symbol, listener: (message: any, sendHandle: any) => void): Readable => {
listeners.on(`stdout/${String(event)}`, listener);
if (event === "data") {
listeners.emit("stdout/data", "Starting to serve on 127.0.0.1:9191");
}
return stdout;
});
mockedCP.stdout.once.mockImplementation((event: string | symbol, listener: (message: any, sendHandle: any) => void): Readable => {
listeners.once(`stdout/${String(event)}`, listener);
return stdout;
});
mockedCP.stdout.off.mockImplementation((event: string | symbol, listener: (message: any, sendHandle: any) => void): Readable => {
listeners.off(`stdout/${String(event)}`, listener);
return stdout;
});
mockedCP.stdout.removeListener.mockImplementation((event: string | symbol, listener: (message: any, sendHandle: any) => void): Readable => {
listeners.off(`stdout/${String(event)}`, listener);
return stdout;
});
mockedCP.stdout.removeAllListeners.mockImplementation((event?: string | symbol): Readable => {
listeners.removeAllListeners(event ?? `stdout/${String(event)}`);
return stdout;
});
spawnMock.mockImplementationOnce((command: string): ChildProcess => {
expect(getBasenameOfPath(command).split(".")[0]).toBe("lens-k8s-proxy");
return mockedCP;
});
waitUntilPortIsUsedMock.mockReturnValueOnce(Promise.resolve());
const cluster = createCluster({
id: "foobar",
kubeConfigPath: "minikube-config.yml",
contextName: "minikube",
}, {
clusterServerUrl,
});
proxy = createKubeAuthProxy(cluster, {});
});
it("should call spawn and broadcast errors", async () => {
await proxy.run();
listeners.emit("error", { message: "foobarbat" });
expect(broadcastMessageMock).toBeCalledWith("cluster:foobar:connection-update", { message: "foobarbat", isError: true });
});
it("should call spawn and broadcast exit", async () => {
await proxy.run();
listeners.emit("exit", 0);
expect(broadcastMessageMock).toBeCalledWith("cluster:foobar:connection-update", { message: "proxy exited with code: 0", isError: false });
});
it("should call spawn and broadcast errors from stderr", async () => {
await proxy.run();
listeners.emit("stderr/data", "an error");
expect(broadcastMessageMock).toBeCalledWith("cluster:foobar:connection-update", { message: "an error", isError: true });
});
it("should call spawn and broadcast stdout serving info", async () => {
await proxy.run();
expect(broadcastMessageMock).toBeCalledWith("cluster:foobar:connection-update", { message: "Authentication proxy started", isError: false });
});
it("should call spawn and broadcast stdout other info", async () => {
await proxy.run();
listeners.emit("stdout/data", "some info");
expect(broadcastMessageMock).toBeCalledWith("cluster:foobar:connection-update", { message: "some info", isError: false });
});
});
});
@@ -0,0 +1,324 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getDiForUnitTesting } from "../getDiForUnitTesting";
import { KubeconfigManager } from "../kubeconfig-manager/kubeconfig-manager";
import type { Cluster } from "../../common/cluster/cluster";
import createKubeconfigManagerInjectable from "../kubeconfig-manager/create-kubeconfig-manager.injectable";
import { createClusterInjectionToken } from "../../common/cluster/create-cluster-injection-token";
import directoryForTempInjectable from "../../common/app-paths/directory-for-temp/directory-for-temp.injectable";
import createContextHandlerInjectable from "../context-handler/create-context-handler.injectable";
import type { DiContainer } from "@ogre-tools/injectable";
import { parse } from "url";
import loggerInjectable from "../../common/logger.injectable";
import type { Logger } from "../../common/logger";
import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
import normalizedPlatformInjectable from "../../common/vars/normalized-platform.injectable";
import kubectlBinaryNameInjectable from "../kubectl/binary-name.injectable";
import kubectlDownloadingNormalizedArchInjectable from "../kubectl/normalized-arch.injectable";
import type { ReadFile } from "../../common/fs/read-file.injectable";
import readFileInjectable from "../../common/fs/read-file.injectable";
import type { AsyncFnMock } from "@async-fn/jest";
import asyncFn from "@async-fn/jest";
import type { WriteFile } from "../../common/fs/write-file.injectable";
import writeFileInjectable from "../../common/fs/write-file.injectable";
import type { PathExists } from "../../common/fs/path-exists.injectable";
import pathExistsInjectable from "../../common/fs/path-exists.injectable";
import type { RemovePath } from "../../common/fs/remove.injectable";
import removePathInjectable from "../../common/fs/remove.injectable";
import pathExistsSyncInjectable from "../../common/fs/path-exists-sync.injectable";
import readJsonSyncInjectable from "../../common/fs/read-json-sync.injectable";
import writeJsonSyncInjectable from "../../common/fs/write-json-sync.injectable";
const clusterServerUrl = "https://192.168.64.3:8443";
describe("kubeconfig manager tests", () => {
let clusterFake: Cluster;
let createKubeconfigManager: (cluster: Cluster) => KubeconfigManager;
let di: DiContainer;
let loggerMock: jest.Mocked<Logger>;
let readFileMock: AsyncFnMock<ReadFile>;
let deleteFileMock: AsyncFnMock<RemovePath>;
let writeFileMock: AsyncFnMock<WriteFile>;
let pathExistsMock: AsyncFnMock<PathExists>;
let kubeConfManager: KubeconfigManager;
let ensureServerMock: AsyncFnMock<() => Promise<void>>;
beforeEach(async () => {
di = getDiForUnitTesting({ doGeneralOverrides: true });
di.override(directoryForTempInjectable, () => "/some-directory-for-temp");
di.override(directoryForUserDataInjectable, () => "/some-directory-for-user-data");
di.override(kubectlBinaryNameInjectable, () => "kubectl");
di.override(kubectlDownloadingNormalizedArchInjectable, () => "amd64");
di.override(normalizedPlatformInjectable, () => "darwin");
di.override(pathExistsSyncInjectable, () => () => { throw new Error("tried call pathExistsSync without override"); });
di.override(readJsonSyncInjectable, () => () => { throw new Error("tried call readJsonSync without override"); });
di.override(writeJsonSyncInjectable, () => () => { throw new Error("tried call writeJsonSync without override"); });
readFileMock = asyncFn();
di.override(readFileInjectable, () => readFileMock);
writeFileMock = asyncFn();
di.override(writeFileInjectable, () => writeFileMock);
pathExistsMock = asyncFn();
di.override(pathExistsInjectable, () => pathExistsMock);
deleteFileMock = asyncFn();
di.override(removePathInjectable, () => deleteFileMock);
loggerMock = {
warn: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
info: jest.fn(),
silly: jest.fn(),
};
di.override(loggerInjectable, () => loggerMock);
ensureServerMock = asyncFn();
di.override(createContextHandlerInjectable, () => (cluster) => ({
restartServer: jest.fn(),
stopServer: jest.fn(),
clusterUrl: parse(cluster.apiUrl),
getApiTarget: jest.fn(),
getPrometheusDetails: jest.fn(),
resolveAuthProxyCa: jest.fn(),
resolveAuthProxyUrl: jest.fn(),
setupPrometheus: jest.fn(),
ensureServer: ensureServerMock,
}));
const createCluster = di.inject(createClusterInjectionToken);
createKubeconfigManager = di.inject(createKubeconfigManagerInjectable);
clusterFake = createCluster({
id: "foo",
contextName: "minikube",
kubeConfigPath: "/minikube-config.yml",
}, {
clusterServerUrl,
});
jest.spyOn(KubeconfigManager.prototype, "resolveProxyUrl", "get").mockReturnValue("https://127.0.0.1:9191/foo");
kubeConfManager = createKubeconfigManager(clusterFake);
});
describe("when calling clear", () => {
it("should resolve immediately", async () => {
await kubeConfManager.clear();
});
it("being called several times shouldn't throw", async () => {
await kubeConfManager.clear();
await kubeConfManager.clear();
await kubeConfManager.clear();
});
});
describe("when getPath() is called initially", () => {
let getPathPromise: Promise<string>;
beforeEach(async () => {
getPathPromise = kubeConfManager.getPath();
});
it("should not call pathExists()", () => {
expect(pathExistsMock).not.toBeCalled();
});
it("should call ensureServer on the cluster context", () => {
expect(ensureServerMock).toBeCalledTimes(1);
});
describe("when ensureServer resolves", () => {
beforeEach(async () => {
await ensureServerMock.resolve();
// clear state of calls
ensureServerMock.mock.calls.length = 0;
});
describe("when reading cluster's kubeconfig resolves", () => {
beforeEach(async () => {
await readFileMock.resolveSpecific(
["/minikube-config.yml"],
JSON.stringify({
apiVersion: "v1",
clusters: [{
name: "minikube",
cluster: {
server: clusterServerUrl,
},
}],
contexts: [{
context: {
cluster: "minikube",
user: "minikube",
},
name: "minikube",
}],
users: [{
name: "minikube",
}],
kind: "Config",
preferences: {},
}),
);
});
describe("when writing out new proxy kubeconfig resolves", () => {
beforeEach(async () => {
await writeFileMock.resolveSpecific(
[
"/some-directory-for-temp/kubeconfig-foo",
"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: minikube\nclusters:\n - name: minikube\n cluster:\n certificate-authority-data: PGNhLWRhdGE+\n server: https://127.0.0.1:9191/foo\n insecure-skip-tls-verify: false\ncontexts:\n - name: minikube\n context:\n cluster: minikube\n user: proxy\nusers:\n - name: proxy\n user:\n username: lens\n password: fake\n",
],
);
});
it("should allow getPath to resolve with the path to the kubeconfig", async () => {
expect(await getPathPromise).toBe("/some-directory-for-temp/kubeconfig-foo");
});
describe("when calling clear", () => {
let clearPromise: Promise<void>;
beforeEach(() => {
clearPromise = kubeConfManager.clear();
});
it("should call deleteFile", () => {
expect(deleteFileMock).toBeCalledTimes(1);
});
describe("when deleteFile resolves", () => {
beforeEach(async () => {
await deleteFileMock.resolveSpecific(
["/some-directory-for-temp/kubeconfig-foo"],
);
});
it("should allow clear to resolve", async () => {
await clearPromise;
});
});
describe("when deleteFile rejects with ENOENT", () => {
beforeEach(async () => {
await deleteFileMock.resolveSpecific(
["/some-directory-for-temp/kubeconfig-foo"],
Promise.reject(Object.assign(new Error("file not found"), {
code: "ENOENT",
})),
);
});
it("should allow clear to resolve", async () => {
await clearPromise;
});
});
it("when deleteFile rejects with some other error; clear should also reject", async () => {
const expectPromise = expect(clearPromise).rejects.toBeDefined();
await deleteFileMock.reject(new Error("some other error"));
await expectPromise;
});
});
describe("when calling getPath a second time", () => {
let getPathPromise: Promise<string>;
beforeEach(async () => {
getPathPromise = kubeConfManager.getPath();
});
it("should call pathExists", () => {
expect(pathExistsMock).toBeCalledTimes(1);
});
describe("when pathExists resoves to true", () => {
beforeEach(async () => {
await pathExistsMock.resolveSpecific(
["/some-directory-for-temp/kubeconfig-foo"],
true,
);
});
it("always getPath to resolve with path", async () => {
expect(await getPathPromise).toBe("/some-directory-for-temp/kubeconfig-foo");
});
});
describe("when pathExists resoves to false", () => {
beforeEach(async () => {
await pathExistsMock.resolveSpecific(
["/some-directory-for-temp/kubeconfig-foo"],
false,
);
});
it("should call ensureServer on the cluster context", () => {
expect(ensureServerMock).toBeCalledTimes(1);
});
describe("when ensureServer resolves", () => {
beforeEach(async () => {
await ensureServerMock.resolve();
});
describe("when reading cluster's kubeconfig resolves", () => {
beforeEach(async () => {
await readFileMock.resolveSpecific(
["/minikube-config.yml"],
JSON.stringify({
apiVersion: "v1",
clusters: [{
name: "minikube",
cluster: {
server: clusterServerUrl,
},
}],
contexts: [{
context: {
cluster: "minikube",
user: "minikube",
},
name: "minikube",
}],
users: [{
name: "minikube",
}],
kind: "Config",
preferences: {},
}),
);
});
describe("when writing out new proxy kubeconfig resolves", () => {
beforeEach(async () => {
await writeFileMock.resolveSpecific(
[
"/some-directory-for-temp/kubeconfig-foo",
"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: minikube\nclusters:\n - name: minikube\n cluster:\n certificate-authority-data: PGNhLWRhdGE+\n server: https://127.0.0.1:9191/foo\n insecure-skip-tls-verify: false\ncontexts:\n - name: minikube\n context:\n cluster: minikube\n user: proxy\nusers:\n - name: proxy\n user:\n username: lens\n password: fake\n",
],
);
});
it("should allow getPath to resolve with the path to the kubeconfig", async () => {
expect(await getPathPromise).toBe("/some-directory-for-temp/kubeconfig-foo");
});
});
});
});
});
});
});
});
});
});
});
@@ -0,0 +1,40 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { isLongRunningRequest } from "../lens-proxy/lens-proxy";
describe("isLongRunningRequest", () => {
it("returns true on watches", () => {
["watch=true", "watch=1", "watch"].forEach((param) => {
expect(
isLongRunningRequest(`/api/v1/namespaces/default/pods?${param}`),
).toBeTruthy();
});
});
it("returns false on disabled watches", () => {
["watch=false", "watch=0", ""].forEach((param) => {
expect(
isLongRunningRequest(`/api/v1/namespaces/default/pods?${param}`),
).toBeFalsy();
});
});
it("returns true on follows", () => {
["follow=true", "follow=1", "follow"].forEach((param) => {
expect(
isLongRunningRequest(`/api/v1/namespaces/default/pods/foo/log?${param}`),
).toBeTruthy();
});
});
it("returns false on disabled follows", () => {
["follow=false", "follow=0", ""].forEach((param) => {
expect(
isLongRunningRequest(`/api/v1/namespaces/default/pods/foo/log?${param}`),
).toBeFalsy();
});
});
});
@@ -0,0 +1,20 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { clearKubeconfigEnvVars } from "../utils/clear-kube-env-vars";
describe("clearKubeconfigEnvVars tests", () => {
it("should not touch non kubeconfig keys", () => {
expect(clearKubeconfigEnvVars({ a: "22" })).toStrictEqual({ a: "22" });
});
it("should remove a single kubeconfig key", () => {
expect(clearKubeconfigEnvVars({ a: "22", kubeconfig: "1" })).toStrictEqual({ a: "22" });
});
it("should remove a two kubeconfig key", () => {
expect(clearKubeconfigEnvVars({ a: "22", kubeconfig: "1", kUbeconfig: "1" })).toStrictEqual({ a: "22" });
});
});
@@ -0,0 +1,48 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { LensApiRequest, Route } from "../router/route";
import staticFileRouteInjectable from "../routes/files/static-file-route.injectable";
import { getDiForUnitTesting } from "../getDiForUnitTesting";
describe("static-file-route", () => {
let handleStaticFileRoute: Route<Buffer, "/{path*}">;
beforeEach(() => {
const di = getDiForUnitTesting({ doGeneralOverrides: true });
handleStaticFileRoute = di.inject(staticFileRouteInjectable);
});
it("blocks path traversal attacks", async () => {
const request = {
params: {
path: "../index.ts",
},
raw: {},
} as LensApiRequest<"/{path*}">;
const result = await handleStaticFileRoute.handler(request);
expect(result).toEqual({ statusCode: 404 });
});
it("serves files under static root", async () => {
const req: any = {
url: "",
};
const request = {
params: {
path: "router.test.ts",
},
raw: { req },
} as LensApiRequest<"/{path*}">;
const result = await handleStaticFileRoute.handler(request);
expect(result).toEqual({ statusCode: 404 });
});
});
@@ -0,0 +1,18 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { appPathsChannel } from "../../common/app-paths/app-paths-channel";
import appPathsInjectable from "../../common/app-paths/app-paths.injectable";
import { getRequestChannelListenerInjectable } from "../utils/channel/channel-listeners/listener-tokens";
const appPathsRequestChannelListenerInjectable = getRequestChannelListenerInjectable({
channel: appPathsChannel,
handler: (di) => {
const appPaths = di.inject(appPathsInjectable);
return () => appPaths;
},
});
export default appPathsRequestChannelListenerInjectable;
@@ -0,0 +1,9 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getGlobalOverride } from "../../../common/test-utils/get-global-override";
import directoryForIntegrationTestingInjectable from "./directory-for-integration-testing.injectable";
export default getGlobalOverride(directoryForIntegrationTestingInjectable, () => undefined);
@@ -0,0 +1,13 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
const directoryForIntegrationTestingInjectable = getInjectable({
id: "directory-for-integration-testing",
instantiate: () => process.env.CICD,
causesSideEffects: true,
});
export default directoryForIntegrationTestingInjectable;
@@ -0,0 +1,15 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { fromPairs } from "lodash/fp";
import type { PathName } from "../../common/app-paths/app-path-names";
import { pathNames } from "../../common/app-paths/app-path-names";
import type { AppPaths } from "../../common/app-paths/app-path-injection-token";
interface Dependencies {
getAppPath: (name: PathName) => string;
}
export const getAppPaths = ({ getAppPath }: Dependencies) =>
fromPairs(pathNames.map((name) => [name, getAppPath(name)])) as AppPaths;
@@ -0,0 +1,31 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import type { PathName } from "../../../common/app-paths/app-path-names";
import electronAppInjectable from "../../electron-app/electron-app.injectable";
export type GetElectronAppPath = (name: PathName | "currentApp") => string;
const getElectronAppPathInjectable = getInjectable({
id: "get-electron-app-path",
instantiate: (di): GetElectronAppPath => {
const electronApp = di.inject(electronAppInjectable);
return (name) => {
try {
if (name === "currentApp") {
return electronApp.getAppPath();
}
return electronApp.getPath(name);
} catch (e) {
return "";
}
};
},
});
export default getElectronAppPathInjectable;
@@ -0,0 +1,47 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import electronAppInjectable from "../../electron-app/electron-app.injectable";
import getElectronAppPathInjectable from "./get-electron-app-path.injectable";
import { getDiForUnitTesting } from "../../getDiForUnitTesting";
import type { App } from "electron";
describe("get-electron-app-path", () => {
let getElectronAppPath: (name: string) => string;
beforeEach(() => {
const di = getDiForUnitTesting({ doGeneralOverrides: false });
const appStub = {
name: "some-app-name",
getPath: (name: string) => {
if (name !== "some-existing-name") {
throw new Error("irrelevant");
}
return "some-existing-app-path";
},
// eslint-disable-next-line unused-imports/no-unused-vars-ts
setPath: (_, __) => undefined,
} as App;
di.override(electronAppInjectable, () => appStub);
getElectronAppPath = di.inject(getElectronAppPathInjectable) as (name: string) => string;
});
it("given app path exists, when called, returns app path", () => {
const actual = getElectronAppPath("some-existing-name");
expect(actual).toBe("some-existing-app-path");
});
it("given app path does not exist, when called, returns null", () => {
const actual = getElectronAppPath("some-non-existing-name");
expect(actual).toBe("");
});
});
@@ -0,0 +1,21 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import type { PathName } from "../../../common/app-paths/app-path-names";
import electronAppInjectable from "../../electron-app/electron-app.injectable";
export type SetElectronAppPath = (name: PathName, path: string) => void;
const setElectronAppPathInjectable = getInjectable({
id: "set-electron-app-path",
instantiate: (di): SetElectronAppPath => {
const electronApp = di.inject(electronAppInjectable);
return (name, path) => electronApp.setPath(name, path);
},
});
export default setElectronAppPathInjectable;
@@ -0,0 +1,57 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import type { AppPaths } from "../../common/app-paths/app-path-injection-token";
import getElectronAppPathInjectable from "./get-electron-app-path/get-electron-app-path.injectable";
import setElectronAppPathInjectable from "./set-electron-app-path/set-electron-app-path.injectable";
import directoryForIntegrationTestingInjectable from "./directory-for-integration-testing/directory-for-integration-testing.injectable";
import appPathsStateInjectable from "../../common/app-paths/app-paths-state.injectable";
import { pathNames } from "../../common/app-paths/app-path-names";
import { fromPairs, map } from "lodash/fp";
import { pipeline } from "@ogre-tools/fp";
import joinPathsInjectable from "../../common/path/join-paths.injectable";
import appNameInjectable from "../../common/vars/app-name.injectable";
import { appPathsRunnablePhaseInjectionToken } from "../start-main-application/runnable-tokens/phases";
const setupAppPathsInjectable = getInjectable({
id: "setup-app-paths",
instantiate: (di) => {
const setElectronAppPath = di.inject(setElectronAppPathInjectable);
const appName = di.inject(appNameInjectable);
const getElectronAppPath = di.inject(getElectronAppPathInjectable);
const appPathsState = di.inject(appPathsStateInjectable);
const directoryForIntegrationTesting = di.inject(directoryForIntegrationTestingInjectable);
const joinPaths = di.inject(joinPathsInjectable);
return {
id: "setup-app-paths",
run: () => {
if (directoryForIntegrationTesting) {
setElectronAppPath("appData", directoryForIntegrationTesting);
}
const appDataPath = getElectronAppPath("appData");
setElectronAppPath("userData", joinPaths(appDataPath, appName));
const appPaths = pipeline(
pathNames,
map(name => [name, getElectronAppPath(name)]),
fromPairs,
) as AppPaths;
appPathsState.set(appPaths);
// NOTE: this is the worse of two evils. This makes sure that `RunnableSync` always is sync
return undefined;
},
};
},
injectionToken: appPathsRunnablePhaseInjectionToken,
});
export default setupAppPathsInjectable;
@@ -0,0 +1,14 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { shouldBaseStoreDisableSyncInIpcListenerInjectionToken } from "../../common/base-store/disable-sync";
const shouldBaseStoreDisableSyncInIpcListenerInjectable = getInjectable({
id: "should-base-store-disable-sync-in-ipc-listener",
instantiate: () => false,
injectionToken: shouldBaseStoreDisableSyncInIpcListenerInjectionToken,
});
export default shouldBaseStoreDisableSyncInIpcListenerInjectable;
@@ -0,0 +1,17 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { baseStoreIpcChannelPrefixesInjectionToken } from "../../common/base-store/channel-prefix";
const baseStoreIpcChannelPrefixInjectable = getInjectable({
id: "base-store-ipc-channel-prefix",
instantiate: () => ({
local: "store-sync-main",
remote: "store-sync-renderer",
}),
injectionToken: baseStoreIpcChannelPrefixesInjectionToken,
});
export default baseStoreIpcChannelPrefixInjectable;
@@ -0,0 +1,25 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { persistStateToConfigInjectionToken } from "../../common/base-store/save-to-file";
import loggerInjectable from "../../common/logger.injectable";
const persistStateToConfigInjectable = getInjectable({
id: "persist-state-to-config",
instantiate: (di) => {
const logger = di.inject(loggerInjectable);
return (config, state) => {
logger.info(`[BASE-STORE]: saving ${config.path}...`);
for (const [key, value] of Object.entries(state)) {
config.set(key, value);
}
};
},
injectionToken: persistStateToConfigInjectionToken,
});
export default persistStateToConfigInjectable;
@@ -0,0 +1,18 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { buildVersionChannel } from "../../common/vars/build-semantic-version.injectable";
import { getRequestChannelListenerInjectable } from "../utils/channel/channel-listeners/listener-tokens";
import buildVersionInjectable from "../vars/build-version/build-version.injectable";
const buildVersionChannelListenerInjectable = getRequestChannelListenerInjectable({
channel: buildVersionChannel,
handler: (di) => {
const buildVersion = di.inject(buildVersionInjectable);
return () => buildVersion.get();
},
});
export default buildVersionChannelListenerInjectable;
@@ -0,0 +1,468 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { observable, ObservableMap } from "mobx";
import type { CatalogEntity } from "../../../common/catalog";
import { loadFromOptions } from "../../../common/kube-helpers";
import type { Cluster } from "../../../common/cluster/cluster";
import { getDiForUnitTesting } from "../../getDiForUnitTesting";
import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
import directoryForTempInjectable from "../../../common/app-paths/directory-for-temp/directory-for-temp.injectable";
import { iter, strictGet } from "../../../common/utils";
import type { ComputeKubeconfigDiff } from "../kubeconfig-sync/compute-diff.injectable";
import computeKubeconfigDiffInjectable from "../kubeconfig-sync/compute-diff.injectable";
import type { ConfigToModels } from "../kubeconfig-sync/config-to-models.injectable";
import configToModelsInjectable from "../kubeconfig-sync/config-to-models.injectable";
import kubeconfigSyncManagerInjectable from "../kubeconfig-sync/manager.injectable";
import type { KubeconfigSyncManager } from "../kubeconfig-sync/manager";
import type { KubeconfigSyncValue } from "../../../common/user-store";
import kubeconfigSyncsInjectable from "../../../common/user-store/kubeconfig-syncs.injectable";
import getClusterByIdInjectable from "../../../common/cluster-store/get-by-id.injectable";
import type { DiContainer } from "@ogre-tools/injectable";
import type { AsyncFnMock } from "@async-fn/jest";
import type { Stat } from "../../../common/fs/stat.injectable";
import asyncFn from "@async-fn/jest";
import statInjectable from "../../../common/fs/stat.injectable";
import type { Watcher } from "../../../common/fs/watch/watch.injectable";
import watchInjectable from "../../../common/fs/watch/watch.injectable";
import EventEmitter from "events";
import type { ReadStream, Stats } from "fs";
import createReadFileStreamInjectable from "../../../common/fs/create-read-file-stream.injectable";
import pathExistsSyncInjectable from "../../../common/fs/path-exists-sync.injectable";
import pathExistsInjectable from "../../../common/fs/path-exists.injectable";
import readJsonSyncInjectable from "../../../common/fs/read-json-sync.injectable";
import writeJsonSyncInjectable from "../../../common/fs/write-json-sync.injectable";
describe("kubeconfig-sync.source tests", () => {
let computeKubeconfigDiff: ComputeKubeconfigDiff;
let configToModels: ConfigToModels;
let kubeconfigSyncs: ObservableMap<string, KubeconfigSyncValue>;
let clusters: Map<string, Cluster>;
let di: DiContainer;
beforeEach(async () => {
di = getDiForUnitTesting({ doGeneralOverrides: true });
di.override(directoryForUserDataInjectable, () => "/some-directory-for-user-data");
di.override(directoryForTempInjectable, () => "/some-directory-for-temp");
di.override(pathExistsInjectable, () => () => { throw new Error("tried call pathExists without override"); });
di.override(pathExistsSyncInjectable, () => () => { throw new Error("tried call pathExistsSync without override"); });
di.override(readJsonSyncInjectable, () => () => { throw new Error("tried call readJsonSync without override"); });
di.override(writeJsonSyncInjectable, () => () => { throw new Error("tried call writeJsonSync without override"); });
clusters = new Map();
di.override(getClusterByIdInjectable, () => id => clusters.get(id));
kubeconfigSyncs = observable.map();
di.override(kubeconfigSyncsInjectable, () => kubeconfigSyncs);
computeKubeconfigDiff = di.inject(computeKubeconfigDiffInjectable);
configToModels = di.inject(configToModelsInjectable);
});
describe("configsToModels", () => {
it("should filter out invalid split configs", () => {
const config = loadFromOptions({
clusters: [],
users: [],
contexts: [],
currentContext: "foobar",
});
expect(configToModels(config, "").length).toBe(0);
});
it("should keep a single valid split config", () => {
const config = loadFromOptions({
clusters: [{
name: "cluster-name",
server: "1.2.3.4",
skipTLSVerify: false,
}],
users: [{
name: "user-name",
}],
contexts: [{
cluster: "cluster-name",
name: "context-name",
user: "user-name",
}],
currentContext: "foobar",
});
const models = configToModels(config, "/bar");
expect(models.length).toBe(1);
expect(models[0][0].contextName).toBe("context-name");
expect(models[0][0].kubeConfigPath).toBe("/bar");
});
});
describe("computeKubeconfigDiff", () => {
it("should leave an empty source empty if there are no entries", () => {
const contents = "";
const rootSource = new ObservableMap<string, [Cluster, CatalogEntity]>();
const filePath = "/bar";
computeKubeconfigDiff(contents, rootSource, filePath);
expect(rootSource.size).toBe(0);
});
it("should add only the valid clusters to the source", () => {
const contents = JSON.stringify({
clusters: [{
name: "cluster-name",
cluster: {
server: "1.2.3.4",
},
skipTLSVerify: false,
}],
users: [{
name: "user-name",
}],
contexts: [{
name: "context-name",
context: {
cluster: "cluster-name",
user: "user-name",
},
}, {
name: "context-the-second",
context: {
cluster: "missing-cluster",
user: "user-name",
},
}],
currentContext: "foobar",
});
const rootSource = new ObservableMap<string, [Cluster, CatalogEntity]>();
const filePath = "/bar";
computeKubeconfigDiff(contents, rootSource, filePath);
expect(rootSource.size).toBe(1);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const c = (iter.first(rootSource.values())!)[0];
expect(c.kubeConfigPath).toBe("/bar");
expect(c.contextName).toBe("context-name");
});
it("should remove a cluster when it is removed from the contents", () => {
const contents = JSON.stringify({
clusters: [{
name: "cluster-name",
cluster: {
server: "1.2.3.4",
},
skipTLSVerify: false,
}],
users: [{
name: "user-name",
}],
contexts: [{
name: "context-name",
context: {
cluster: "cluster-name",
user: "user-name",
},
}, {
name: "context-the-second",
context: {
cluster: "missing-cluster",
user: "user-name",
},
}],
currentContext: "foobar",
});
const rootSource = new ObservableMap<string, [Cluster, CatalogEntity]>();
const filePath = "/bar";
computeKubeconfigDiff(contents, rootSource, filePath);
expect(rootSource.size).toBe(1);
const c = rootSource.values().next().value[0] as Cluster;
expect(c.kubeConfigPath).toBe("/bar");
expect(c.contextName).toBe("context-name");
computeKubeconfigDiff("{}", rootSource, filePath);
expect(rootSource.size).toBe(0);
});
it("should remove only the cluster that it is removed from the contents", () => {
const contents = JSON.stringify({
clusters: [{
name: "cluster-name",
cluster: {
server: "1.2.3.4",
},
skipTLSVerify: false,
}],
users: [{
name: "user-name",
}, {
name: "user-name-2",
}],
contexts: [{
name: "context-name",
context: {
cluster: "cluster-name",
user: "user-name",
},
}, {
name: "context-name-2",
context: {
cluster: "cluster-name",
user: "user-name-2",
},
}, {
name: "context-the-second",
context: {
cluster: "missing-cluster",
user: "user-name",
},
}],
currentContext: "foobar",
});
const rootSource = new ObservableMap<string, [Cluster, CatalogEntity]>();
const filePath = "/bar";
computeKubeconfigDiff(contents, rootSource, filePath);
expect(rootSource.size).toBe(2);
{
const c = rootSource.values().next().value[0] as Cluster;
expect(c.kubeConfigPath).toBe("/bar");
expect(["context-name", "context-name-2"].includes(c.contextName)).toBe(true);
}
const newContents = JSON.stringify({
clusters: [{
name: "cluster-name",
cluster: {
server: "1.2.3.4",
},
skipTLSVerify: false,
}],
users: [{
name: "user-name",
}, {
name: "user-name-2",
}],
contexts: [{
name: "context-name",
context: {
cluster: "cluster-name",
user: "user-name",
},
}, {
name: "context-the-second",
context: {
cluster: "missing-cluster",
user: "user-name",
},
}],
currentContext: "foobar",
});
computeKubeconfigDiff(newContents, rootSource, filePath);
expect(rootSource.size).toBe(1);
{
const c = rootSource.values().next().value[0] as Cluster;
expect(c.kubeConfigPath).toBe("/bar");
expect(c.contextName).toBe("context-name");
}
});
});
describe("given a config file at /foobar/config", () => {
let manager: KubeconfigSyncManager;
let watchInstances: Map<string, Watcher<true>>;
let firstReadFoobarConfigSteam: ReadStream;
let secondReadFoobarConfigSteam: ReadStream;
let statMock: AsyncFnMock<Stat>;
beforeEach(() => {
statMock = asyncFn();
di.override(statInjectable, () => statMock);
watchInstances = new Map();
di.override(watchInjectable, () => (path) => {
const fakeWatchInstance = getFakeWatchInstance();
watchInstances.set(path, fakeWatchInstance);
return fakeWatchInstance;
});
di.override(createReadFileStreamInjectable, () => (filePath) => {
if (filePath !== "/foobar/config") {
throw new Error(`unexpected file path "${filePath}"`);
}
if (!firstReadFoobarConfigSteam) {
return firstReadFoobarConfigSteam = getFakeReadStream(filePath);
}
if (!secondReadFoobarConfigSteam) {
return secondReadFoobarConfigSteam = getFakeReadStream(filePath);
}
return getFakeReadStream(filePath);
});
manager = di.inject(kubeconfigSyncManagerInjectable);
});
afterEach(() => {
(firstReadFoobarConfigSteam as any) = undefined;
(secondReadFoobarConfigSteam as any) = undefined;
});
it("should not find any entities", () => {
expect(manager.source.get()).toEqual([]);
});
describe("when sync has started", () => {
beforeEach(() => {
manager.startSync();
});
it("should not find any entities", () => {
expect(manager.source.get()).toEqual([]);
});
describe("when a file sync target for /foobar/config is added", () => {
beforeEach(() => {
kubeconfigSyncs.set("/foobar/config", {});
});
describe("when stat resolves as not a directory", () => {
beforeEach(async () => {
await statMock.resolveSpecific(["/foobar/config"], {
isDirectory: () => false,
} as Stats);
});
describe("when the watch emits that the file is added", () => {
beforeEach(() => {
strictGet(watchInstances, "/foobar/config").emit("add", "/foobar/config", {
size: foobarConfig.length,
} as Stats);
});
it("starts to read the file", () => {
expect(firstReadFoobarConfigSteam).toBeDefined();
});
describe("when the data is read in", () => {
beforeEach(() => {
firstReadFoobarConfigSteam.emit("data", Buffer.from(foobarConfig));
firstReadFoobarConfigSteam.emit("end");
firstReadFoobarConfigSteam.emit("close");
});
it("should find a single entity", () => {
expect(manager.source.get().length).toBe(1);
});
describe("when a folder sync target for /foobar is added", () => {
beforeEach(() => {
kubeconfigSyncs.set("/foobar", {});
});
describe("when stat resolves as not a directory", () => {
beforeEach(async () => {
await statMock.resolveSpecific(["/foobar"], {
isDirectory: () => true,
} as Stats);
});
describe("when the watch emits that the file is added", () => {
beforeEach(() => {
strictGet(watchInstances, "/foobar").emit("add", "/foobar/config", {
size: foobarConfig.length,
} as Stats);
});
it("starts to read the file", () => {
expect(secondReadFoobarConfigSteam).toBeDefined();
});
describe("when the data is read in", () => {
beforeEach(() => {
secondReadFoobarConfigSteam.emit("data", Buffer.from(foobarConfig));
secondReadFoobarConfigSteam.emit("end");
secondReadFoobarConfigSteam.emit("close");
});
it("should still only find a single entity", () => {
expect(manager.source.get().length).toBe(1);
});
});
});
});
});
});
});
});
});
});
});
});
const getFakeWatchInstance = (): Watcher<true> => {
return Object.assign(new EventEmitter(), {
close: jest.fn().mockImplementation(async () => {}),
});
};
const getFakeReadStream = (path: string): ReadStream => {
return Object.assign(new EventEmitter(), {
path,
close: () => {},
push: () => true,
read: () => {},
}) as unknown as ReadStream;
};
const foobarConfig = JSON.stringify({
clusters: [{
name: "cluster-name",
cluster: {
server: "1.2.3.4",
},
skipTLSVerify: false,
}],
users: [{
name: "user-name",
}],
contexts: [{
name: "context-name",
context: {
cluster: "cluster-name",
user: "user-name",
},
}, {
name: "context-the-second",
context: {
cluster: "missing-cluster",
user: "user-name",
},
}],
currentContext: "foobar",
});
@@ -0,0 +1,6 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
export { syncWeblinks } from "./weblinks";
@@ -0,0 +1,102 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { createHash } from "crypto";
import type { ObservableMap } from "mobx";
import { action } from "mobx";
import { homedir } from "os";
import directoryForKubeConfigsInjectable from "../../../common/app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable";
import type { CatalogEntity } from "../../../common/catalog";
import getClusterByIdInjectable from "../../../common/cluster-store/get-by-id.injectable";
import type { Cluster } from "../../../common/cluster/cluster";
import { loadConfigFromString } from "../../../common/kube-helpers";
import clustersThatAreBeingDeletedInjectable from "../../cluster/are-being-deleted.injectable";
import { catalogEntityFromCluster } from "../../cluster/manager";
import createClusterInjectable from "../../create-cluster/create-cluster.injectable";
import configToModelsInjectable from "./config-to-models.injectable";
import kubeconfigSyncLoggerInjectable from "./logger.injectable";
export type ComputeKubeconfigDiff = (contents: string, source: ObservableMap<string, [Cluster, CatalogEntity]>, filePath: string) => void;
const computeKubeconfigDiffInjectable = getInjectable({
id: "compute-kubeconfig-diff",
instantiate: (di): ComputeKubeconfigDiff => {
const directoryForKubeConfigs = di.inject(directoryForKubeConfigsInjectable);
const createCluster = di.inject(createClusterInjectable);
const clustersThatAreBeingDeleted = di.inject(clustersThatAreBeingDeletedInjectable);
const configToModels = di.inject(configToModelsInjectable);
const logger = di.inject(kubeconfigSyncLoggerInjectable);
const getClusterById = di.inject(getClusterByIdInjectable);
return action((contents, source, filePath) => {
try {
const { config, error } = loadConfigFromString(contents);
if (error) {
logger.warn(`encountered errors while loading config: ${error.message}`, { filePath, details: error.details });
}
const rawModels = configToModels(config, filePath);
const models = new Map(rawModels.map(([model, configData]) => [model.contextName, [model, configData] as const]));
logger.debug(`File now has ${models.size} entries`, { filePath });
for (const [contextName, value] of source) {
const data = models.get(contextName);
// remove and disconnect clusters that were removed from the config
if (!data) {
// remove from the deleting set, so that if a new context of the same name is added, it isn't marked as deleting
clustersThatAreBeingDeleted.delete(value[0].id);
value[0].disconnect();
source.delete(contextName);
logger.debug(`Removed old cluster from sync`, { filePath, contextName });
continue;
}
// TODO: For the update check we need to make sure that the config itself hasn't changed.
// Probably should make it so that cluster keeps a copy of the config in its memory and
// diff against that
// or update the model and mark it as not needed to be added
value[0].updateModel(data[0]);
models.delete(contextName);
logger.debug(`Updated old cluster from sync`, { filePath, contextName });
}
for (const [contextName, [model, configData]] of models) {
// add new clusters to the source
try {
const clusterId = createHash("md5").update(`${filePath}:${contextName}`).digest("hex");
const cluster = getClusterById(clusterId) ?? createCluster({ ...model, id: clusterId }, configData);
if (!cluster.apiUrl) {
throw new Error("Cluster constructor failed, see above error");
}
const entity = catalogEntityFromCluster(cluster);
if (!filePath.startsWith(directoryForKubeConfigs)) {
entity.metadata.labels.file = filePath.replace(homedir(), "~");
}
source.set(contextName, [cluster, entity]);
logger.debug(`Added new cluster from sync`, { filePath, contextName });
} catch (error) {
logger.warn(`Failed to create cluster from model: ${error}`, { filePath, contextName });
}
}
} catch (error) {
logger.warn(`Failed to compute diff: ${error}`, { filePath });
source.clear(); // clear source if we have failed so as to not show outdated information
}
logger.debug("Finished computing diff", { filePath });
});
},
});
export default computeKubeconfigDiffInjectable;
@@ -0,0 +1,42 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { KubeConfig } from "@kubernetes/client-node";
import { getInjectable } from "@ogre-tools/injectable";
import type { ClusterConfigData, UpdateClusterModel } from "../../../common/cluster-types";
import { splitConfig } from "../../../common/kube-helpers";
import kubeconfigSyncLoggerInjectable from "./logger.injectable";
export type ConfigToModels = (rootConfig: KubeConfig, filePath: string) => [UpdateClusterModel, ClusterConfigData][];
const configToModelsInjectable = getInjectable({
id: "config-to-models",
instantiate: (di): ConfigToModels => {
const logger = di.inject(kubeconfigSyncLoggerInjectable);
return (rootConfig, filePath) => {
const validConfigs: ReturnType<ConfigToModels> = [];
for (const { config, validationResult } of splitConfig(rootConfig)) {
if (validationResult.error) {
logger.debug(`context failed validation: ${validationResult.error}`, { context: config.currentContext, filePath });
} else {
validConfigs.push([
{
kubeConfigPath: filePath,
contextName: config.currentContext,
},
{
clusterServerUrl: validationResult.cluster.server,
},
]);
}
}
return validConfigs;
};
},
});
export default configToModelsInjectable;
@@ -0,0 +1,90 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import type { Stats } from "fs";
import { constants } from "fs";
import type { ObservableMap } from "mobx";
import type { Readable } from "stream";
import type { CatalogEntity } from "../../../common/catalog";
import type { Cluster } from "../../../common/cluster/cluster";
import createReadFileStreamInjectable from "../../../common/fs/create-read-file-stream.injectable";
import type { Disposer } from "../../../common/utils";
import { bytesToUnits, noop } from "../../../common/utils";
import computeKubeconfigDiffInjectable from "./compute-diff.injectable";
import kubeconfigSyncLoggerInjectable from "./logger.injectable";
export interface DiffChangedKubeconfigArgs {
filePath: string;
source: ObservableMap<string, [Cluster, CatalogEntity]>;
stats: Stats;
maxAllowedFileReadSize: number;
}
export type DiffChangedKubeconfig = (args: DiffChangedKubeconfigArgs) => Disposer;
const diffChangedKubeconfigInjectable = getInjectable({
id: "diff-changed-kubeconfig",
instantiate: (di): DiffChangedKubeconfig => {
const computeKubeconfigDiff = di.inject(computeKubeconfigDiffInjectable);
const logger = di.inject(kubeconfigSyncLoggerInjectable);
const createReadFileStream = di.inject(createReadFileStreamInjectable);
return ({ filePath, maxAllowedFileReadSize, source, stats }) => {
logger.debug(`file changed`, { filePath });
if (stats.size >= maxAllowedFileReadSize) {
logger.warn(`skipping ${filePath}: size=${bytesToUnits(stats.size)} is larger than maxSize=${bytesToUnits(maxAllowedFileReadSize)}`);
source.clear();
return noop;
}
const fileReader = createReadFileStream(filePath, {
mode: constants.O_RDONLY,
});
const readStream = fileReader as Readable;
const decoder = new TextDecoder("utf-8", { fatal: true });
let fileString = "";
let closed = false;
const cleanup = () => {
closed = true;
fileReader.close(); // This may not close the stream.
// Artificially marking end-of-stream, as if the underlying resource had
// indicated end-of-file by itself, allows the stream to close.
// This does not cancel pending read operations, and if there is such an
// operation, the process may still not be able to exit successfully
// until it finishes.
fileReader.push(null);
fileReader.read(0);
readStream.removeAllListeners();
};
readStream
.on("data", (chunk: Buffer) => {
try {
fileString += decoder.decode(chunk, { stream: true });
} catch (error) {
logger.warn(`skipping ${filePath}: ${error}`);
source.clear();
cleanup();
}
})
.on("close", () => cleanup())
.on("error", error => {
cleanup();
logger.warn(`failed to read file: ${error}`, { filePath });
})
.on("end", () => {
if (!closed) {
computeKubeconfigDiff(fileString, source, filePath);
}
});
return cleanup;
};
},
});
export default diffChangedKubeconfigInjectable;
@@ -0,0 +1,13 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import prefixedLoggerInjectable from "../../../common/logger/prefixed-logger.injectable";
const kubeconfigSyncLoggerInjectable = getInjectable({
id: "kubeconfig-sync-logger",
instantiate: (di) => di.inject(prefixedLoggerInjectable, "KUBECONFIG-SYNC"),
});
export default kubeconfigSyncLoggerInjectable;
@@ -0,0 +1,23 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import directoryForKubeConfigsInjectable from "../../../common/app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable";
import { KubeconfigSyncManager } from "./manager";
import kubeconfigSyncLoggerInjectable from "./logger.injectable";
import watchKubeconfigFileChangesInjectable from "./watch-file-changes.injectable";
import kubeconfigSyncsInjectable from "../../../common/user-store/kubeconfig-syncs.injectable";
const kubeconfigSyncManagerInjectable = getInjectable({
id: "kubeconfig-sync-manager",
instantiate: (di) => new KubeconfigSyncManager({
directoryForKubeConfigs: di.inject(directoryForKubeConfigsInjectable),
logger: di.inject(kubeconfigSyncLoggerInjectable),
watchKubeconfigFileChanges: di.inject(watchKubeconfigFileChangesInjectable),
kubeconfigSyncs: di.inject(kubeconfigSyncsInjectable),
}),
});
export default kubeconfigSyncManagerInjectable;
@@ -0,0 +1,109 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { IComputedValue, ObservableMap } from "mobx";
import { action, observable, computed, makeObservable, observe } from "mobx";
import type { CatalogEntity } from "../../../common/catalog";
import type { Disposer } from "../../../common/utils";
import { iter } from "../../../common/utils";
import type { KubeconfigSyncValue } from "../../../common/user-store";
import type { Logger } from "../../../common/logger";
import type { WatchKubeconfigFileChanges } from "./watch-file-changes.injectable";
interface KubeconfigSyncManagerDependencies {
readonly directoryForKubeConfigs: string;
readonly logger: Logger;
readonly kubeconfigSyncs: ObservableMap<string, KubeconfigSyncValue>;
watchKubeconfigFileChanges: WatchKubeconfigFileChanges;
}
export class KubeconfigSyncManager {
protected readonly sources = observable.map<string, [IComputedValue<CatalogEntity[]>, Disposer]>();
protected syncListDisposer?: Disposer;
constructor(protected readonly dependencies: KubeconfigSyncManagerDependencies) {
makeObservable(this);
}
public readonly source = computed(() => {
/**
* This prevents multiple overlapping syncs from leading to multiple entities with the same IDs
*/
const seenIds = new Set<string>();
return (
iter.chain(this.sources.values())
.flatMap(([entities]) => entities.get())
.filter(entity => {
const alreadySeen = seenIds.has(entity.getId());
seenIds.add(entity.getId());
return !alreadySeen;
})
.collect(items => [...items])
);
});
@action
startSync(): void {
this.dependencies.logger.info(`starting requested syncs`);
// This must be done so that c&p-ed clusters are visible
this.startNewSync(this.dependencies.directoryForKubeConfigs);
for (const filePath of this.dependencies.kubeconfigSyncs.keys()) {
this.startNewSync(filePath);
}
this.syncListDisposer = observe(this.dependencies.kubeconfigSyncs, change => {
switch (change.type) {
case "add":
this.startNewSync(change.name);
break;
case "delete":
this.stopOldSync(change.name);
break;
}
});
}
@action
stopSync() {
this.dependencies.logger.info(`stopping requested syncs`);
this.syncListDisposer?.();
for (const filePath of this.sources.keys()) {
this.stopOldSync(filePath);
}
}
@action
protected startNewSync(filePath: string): void {
if (this.sources.has(filePath)) {
// don't start a new sync if we already have one
return this.dependencies.logger.debug(`already syncing file/folder`, { filePath });
}
this.sources.set(
filePath,
this.dependencies.watchKubeconfigFileChanges(filePath),
);
this.dependencies.logger.info(`starting sync of file/folder`, { filePath });
this.dependencies.logger.debug(`${this.sources.size} files/folders watched`, { files: Array.from(this.sources.keys()) });
}
@action
protected stopOldSync(filePath: string): void {
if (!this.sources.delete(filePath)) {
// already stopped
return this.dependencies.logger.debug(`no syncing file/folder to stop`, { filePath });
}
this.dependencies.logger.info(`stopping sync of file/folder`, { filePath });
this.dependencies.logger.debug(`${this.sources.size} files/folders watched`, { files: Array.from(this.sources.keys()) });
}
}
@@ -0,0 +1,134 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import GlobToRegExp from "glob-to-regexp";
import type { IComputedValue, ObservableMap } from "mobx";
import { computed, observable } from "mobx";
import path from "path";
import { inspect } from "util";
import type { CatalogEntity } from "../../../common/catalog";
import type { Cluster } from "../../../common/cluster/cluster";
import statInjectable from "../../../common/fs/stat.injectable";
import type { Watcher } from "../../../common/fs/watch/watch.injectable";
import watchInjectable from "../../../common/fs/watch/watch.injectable";
import type { Disposer } from "../../../common/utils";
import { getOrInsertWith, iter } from "../../../common/utils";
import diffChangedKubeconfigInjectable from "./diff-changed-kubeconfig.injectable";
import kubeconfigSyncLoggerInjectable from "./logger.injectable";
export type WatchKubeconfigFileChanges = (filepath: string) => [IComputedValue<CatalogEntity[]>, Disposer];
/**
* This is the list of globs of which files are ignored when under a folder sync
*/
const ignoreGlobs = [
"*.lock", // kubectl lock files
"*.swp", // vim swap files
".DS_Store", // macOS specific
].map(rawGlob => ({
rawGlob,
matcher: GlobToRegExp(rawGlob),
}));
/**
* This should be much larger than any kubeconfig text file
*
* Even if you have a cert-file, key-file, and client-cert files that is only
* 12kb of extra data (at 4096 bytes each) which allows for around 150 entries.
*/
const folderSyncMaxAllowedFileReadSize = 2 * 1024 * 1024; // 2 MiB
const fileSyncMaxAllowedFileReadSize = 16 * folderSyncMaxAllowedFileReadSize; // 32 MiB
const watchKubeconfigFileChangesInjectable = getInjectable({
id: "watch-kubeconfig-file-changes",
instantiate: (di): WatchKubeconfigFileChanges => {
const diffChangedKubeconfig = di.inject(diffChangedKubeconfigInjectable);
const logger = di.inject(kubeconfigSyncLoggerInjectable);
const stat = di.inject(statInjectable);
const watch = di.inject(watchInjectable);
return (filePath) => {
const rootSource = observable.map<string, ObservableMap<string, [Cluster, CatalogEntity]>>();
const derivedSource = computed(() => Array.from(iter.flatMap(rootSource.values(), from => iter.map(from.values(), child => child[1]))));
let watcher: Watcher<true>;
(async () => {
try {
const stats = await stat(filePath);
const isFolderSync = stats.isDirectory();
const cleanupFns = new Map<string, Disposer>();
const maxAllowedFileReadSize = isFolderSync
? folderSyncMaxAllowedFileReadSize
: fileSyncMaxAllowedFileReadSize;
watcher = watch<true>(filePath, {
followSymlinks: true,
depth: isFolderSync ? 0 : 1, // DIRs works with 0 but files need 1 (bug: https://github.com/paulmillr/chokidar/issues/1095)
disableGlobbing: true,
ignorePermissionErrors: true,
usePolling: false,
awaitWriteFinish: {
pollInterval: 100,
stabilityThreshold: 1000,
},
atomic: 150, // for "atomic writes"
alwaysStat: true,
});
watcher
.on("change", (childFilePath, stats): void => {
const cleanup = cleanupFns.get(childFilePath);
if (!cleanup) {
// file was previously ignored, do nothing
return void logger.debug(`${inspect(childFilePath)} that should have been previously ignored has changed. Doing nothing`);
}
cleanup();
cleanupFns.set(childFilePath, diffChangedKubeconfig({
filePath: childFilePath,
source: getOrInsertWith(rootSource, childFilePath, observable.map),
stats,
maxAllowedFileReadSize,
}));
})
.on("add", (childFilePath, stats): void => {
if (isFolderSync) {
const fileName = path.basename(childFilePath);
for (const ignoreGlob of ignoreGlobs) {
if (ignoreGlob.matcher.test(fileName)) {
return void logger.info(`ignoring ${inspect(childFilePath)} due to ignore glob: ${ignoreGlob.rawGlob}`);
}
}
}
cleanupFns.set(childFilePath, diffChangedKubeconfig({
filePath: childFilePath,
source: getOrInsertWith(rootSource, childFilePath, observable.map),
stats,
maxAllowedFileReadSize,
}));
})
.on("unlink", (childFilePath) => {
cleanupFns.get(childFilePath)?.();
cleanupFns.delete(childFilePath);
rootSource.delete(childFilePath);
})
.on("error", error => logger.error(`watching file/folder failed: ${error}`, { filePath }));
} catch (error) {
logger.warn(`failed to start watching changes: ${error}`);
}
})();
return [derivedSource, () => {
watcher?.close();
}];
};
},
});
export default watchKubeconfigFileChangesInjectable;
@@ -0,0 +1,29 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import catalogEntityRegistryInjectable from "../catalog/entity-registry.injectable";
import { generalCatalogEntityInjectionToken } from "../../common/catalog-entities/general-catalog-entities/general-catalog-entity-injection-token";
import { computed } from "mobx";
const syncGeneralCatalogEntitiesInjectable = getInjectable({
id: "sync-general-catalog-entities",
instantiate: (di) => {
const generalCatalogEntities = di.injectMany(generalCatalogEntityInjectionToken);
const catalogEntityRegistry = di.inject(catalogEntityRegistryInjectable);
// TODO: This shouldn't be reactive at all but catalogEntityRegistry accepts only reactive sources
const reactiveGeneralCatalogEntities = computed(() => generalCatalogEntities);
return () => {
catalogEntityRegistry.addComputedSource(
"lens:general",
reactiveGeneralCatalogEntities,
);
};
},
});
export default syncGeneralCatalogEntitiesInjectable;
@@ -0,0 +1,19 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { syncWeblinks } from "./weblinks";
import weblinkStoreInjectable from "../../common/weblinks-store/weblink-store.injectable";
import catalogEntityRegistryInjectable from "../catalog/entity-registry.injectable";
const syncWeblinksInjectable = getInjectable({
id: "sync-weblinks",
instantiate: (di) => syncWeblinks({
weblinkStore: di.inject(weblinkStoreInjectable),
catalogEntityRegistry: di.inject(catalogEntityRegistryInjectable),
}),
});
export default syncWeblinksInjectable;
@@ -0,0 +1,93 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { computed, observable, reaction } from "mobx";
import type { WeblinkStore } from "../../common/weblinks-store/weblink-store";
import { WebLink } from "../../common/catalog-entities";
import type { CatalogEntityRegistry } from "../catalog";
import got from "got";
import type { Disposer } from "../../common/utils";
import { random } from "lodash";
async function validateLink(link: WebLink) {
try {
const response = await got.get(link.spec.url, {
throwHttpErrors: false,
timeout: 20_000,
});
if (response.statusCode >= 200 && response.statusCode < 500) {
link.status.phase = "available";
} else {
link.status.phase = "unavailable";
}
} catch {
link.status.phase = "unavailable";
}
}
interface Dependencies {
weblinkStore: WeblinkStore;
catalogEntityRegistry: CatalogEntityRegistry;
}
export const syncWeblinks = ({ weblinkStore, catalogEntityRegistry }: Dependencies) => () => {
const webLinkEntities = observable.map<string, [WebLink, Disposer]>();
function periodicallyCheckLink(link: WebLink): Disposer {
validateLink(link);
let interval: NodeJS.Timeout;
const timeout = setTimeout(() => {
interval = setInterval(() => validateLink(link), 60 * 60 * 1000); // every 60 minutes
}, random(0, 5 * 60 * 1000, false)); // spread out over 5 minutes
return () => {
clearTimeout(timeout);
clearInterval(interval);
};
}
reaction(() => weblinkStore.weblinks, (links) => {
const seenWeblinks = new Set<string>();
for (const weblinkData of links) {
seenWeblinks.add(weblinkData.id);
if (!webLinkEntities.has(weblinkData.id)) {
const link = new WebLink({
metadata: {
uid: weblinkData.id,
name: weblinkData.name,
source: "local",
labels: {},
},
spec: {
url: weblinkData.url,
},
status: {
phase: "available",
active: true,
},
});
webLinkEntities.set(weblinkData.id, [
link,
periodicallyCheckLink(link),
]);
}
}
// Stop checking and remove weblinks that are no longer in the store
for (const [weblinkId, [, disposer]] of webLinkEntities) {
if (!seenWeblinks.has(weblinkId)) {
disposer();
webLinkEntities.delete(weblinkId);
}
}
}, { fireImmediately: true });
catalogEntityRegistry.addComputedSource("weblinks", computed(() => Array.from(webLinkEntities.values(), ([link]) => link)));
};
@@ -0,0 +1,10 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { debounce } from "lodash";
import { getGlobalOverride } from "../../common/test-utils/get-global-override";
import catalogSyncBroadcasterInjectable from "./broadcaster.injectable";
export default getGlobalOverride(catalogSyncBroadcasterInjectable, () => debounce(() => {}));
@@ -0,0 +1,29 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { debounce } from "lodash";
import type { CatalogEntity } from "../../common/catalog";
import broadcastMessageInjectable from "../../common/ipc/broadcast-message.injectable";
import { catalogItemsChannel } from "../../common/ipc/catalog";
const catalogSyncBroadcasterInjectable = getInjectable({
id: "catalog-sync-broadcaster",
instantiate: (di) => {
const broadcastMessage = di.inject(broadcastMessageInjectable);
return debounce(
(items: CatalogEntity[]) => {
broadcastMessage(catalogItemsChannel, items);
},
100,
{
leading: true,
trailing: true,
},
);
},
});
export default catalogSyncBroadcasterInjectable;
@@ -0,0 +1,42 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { reaction } from "mobx";
import ipcMainInjectionToken from "../../common/ipc/ipc-main-injection-token";
import { catalogInitChannel } from "../../common/ipc/catalog";
import { disposer, toJS } from "../../common/utils";
import { getStartableStoppable } from "../../common/utils/get-startable-stoppable";
import catalogEntityRegistryInjectable from "../catalog/entity-registry.injectable";
import catalogSyncBroadcasterInjectable from "./broadcaster.injectable";
const catalogSyncToRendererInjectable = getInjectable({
id: "catalog-sync-to-renderer",
instantiate: (di) => {
const catalogEntityRegistry = di.inject(catalogEntityRegistryInjectable);
const ipcMain = di.inject(ipcMainInjectionToken);
const catalogSyncBroadcaster = di.inject(catalogSyncBroadcasterInjectable);
return getStartableStoppable(
"catalog-sync",
() => {
const initChannelHandler = () => catalogSyncBroadcaster(toJS(catalogEntityRegistry.items));
ipcMain.on(catalogInitChannel, initChannelHandler);
return disposer(
() => ipcMain.off(catalogInitChannel, initChannelHandler),
reaction(() => toJS(catalogEntityRegistry.items), (items) => {
catalogSyncBroadcaster(items);
}, {
fireImmediately: true,
}),
);
},
);
},
});
export default catalogSyncToRendererInjectable;
@@ -0,0 +1,20 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { toJS } from "../../common/utils";
import catalogEntityRegistryInjectable from "../catalog/entity-registry.injectable";
import catalogSyncBroadcasterInjectable from "./broadcaster.injectable";
const pushCatalogToRendererInjectable = getInjectable({
id: "push-catalog-to-renderer",
instantiate: (di) => {
const catalogEntityRegistry = di.inject(catalogEntityRegistryInjectable);
const catalogSyncBroadcaster = di.inject(catalogSyncBroadcasterInjectable);
return () => catalogSyncBroadcaster(toJS(catalogEntityRegistry.items));
},
});
export default pushCatalogToRendererInjectable;
@@ -0,0 +1,28 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { afterRootFrameIsReadyInjectionToken } from "../start-main-application/runnable-tokens/after-root-frame-is-ready-injection-token";
import catalogSyncToRendererInjectable from "./catalog-sync-to-renderer.injectable";
const startCatalogSyncInjectable = getInjectable({
id: "start-catalog-sync",
instantiate: (di) => {
const catalogSyncToRenderer = di.inject(catalogSyncToRendererInjectable);
return {
id: "start-catalog-sync",
run: () => {
if (!catalogSyncToRenderer.started) {
catalogSyncToRenderer.start();
}
},
};
},
injectionToken: afterRootFrameIsReadyInjectionToken,
});
export default startCatalogSyncInjectable;
@@ -0,0 +1,30 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import catalogSyncToRendererInjectable from "./catalog-sync-to-renderer.injectable";
import { beforeQuitOfFrontEndInjectionToken } from "../start-main-application/runnable-tokens/before-quit-of-front-end-injection-token";
const stopCatalogSyncInjectable = getInjectable({
id: "stop-catalog-sync",
instantiate: (di) => {
const catalogSyncToRenderer = di.inject(catalogSyncToRendererInjectable);
return {
id: "stop-catalog-sync",
run: () => {
if (catalogSyncToRenderer.started) {
catalogSyncToRenderer.stop();
}
return undefined;
},
};
},
injectionToken: beforeQuitOfFrontEndInjectionToken,
});
export default stopCatalogSyncInjectable;
@@ -0,0 +1,126 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { observable, reaction } from "mobx";
import type { WebLinkSpec, WebLinkStatus } from "../../../common/catalog-entities";
import { WebLink } from "../../../common/catalog-entities";
import type { CatalogEntityMetadata } from "../../../common/catalog";
import { CatalogEntity } from "../../../common/catalog";
import { getDiForUnitTesting } from "../../getDiForUnitTesting";
import catalogEntityRegistryInjectable from "../entity-registry.injectable";
import type { CatalogEntityRegistry } from "../entity-registry";
class InvalidEntity extends CatalogEntity<CatalogEntityMetadata, WebLinkStatus, WebLinkSpec> {
public readonly apiVersion = "entity.k8slens.dev/v1alpha1";
public readonly kind = "Invalid";
async onRun() {
return;
}
public onSettingsOpen(): void {
return;
}
public onDetailsOpen(): void {
return;
}
public onContextMenuOpen(): void {
return;
}
}
describe("CatalogEntityRegistry", () => {
let entityRegistry: CatalogEntityRegistry;
const entity = new WebLink({
metadata: {
uid: "test",
name: "test-link",
source: "test",
labels: {},
},
spec: {
url: "https://k8slens.dev",
},
status: {
phase: "available",
},
});
const invalidEntity = new InvalidEntity({
metadata: {
uid: "invalid",
name: "test-link",
source: "test",
labels: {},
},
spec: {
url: "https://k8slens.dev",
},
status: {
phase: "available",
},
});
beforeEach(() => {
const di = getDiForUnitTesting({ doGeneralOverrides: true });
entityRegistry = di.inject(catalogEntityRegistryInjectable);
});
describe("addSource", () => {
it ("allows to add an observable source", () => {
const source = observable.array<WebLink>([]);
entityRegistry.addObservableSource("test", source);
expect(entityRegistry.items.length).toEqual(0);
source.push(entity);
expect(entityRegistry.items.length).toEqual(1);
});
it ("added source change triggers reaction", (done) => {
const source = observable.array<WebLink>([]);
entityRegistry.addObservableSource("test", source);
reaction(() => entityRegistry.items, () => {
done();
});
source.push(entity);
});
});
describe("removeSource", () => {
it ("removes source", () => {
const source = observable.array<WebLink>([]);
entityRegistry.addObservableSource("test", source);
source.push(entity);
entityRegistry.removeSource("test");
expect(entityRegistry.items.length).toEqual(0);
});
});
describe("items", () => {
it("returns added items", () => {
expect(entityRegistry.items.length).toBe(0);
const source = observable.array([entity]);
entityRegistry.addObservableSource("test", source);
expect(entityRegistry.items.length).toBe(1);
});
it("does not return items without matching category", () => {
const source = observable.array([invalidEntity]);
entityRegistry.addObservableSource("test", source);
expect(entityRegistry.items.length).toBe(0);
});
});
});
@@ -0,0 +1,17 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import hasCategoryForEntityInjectable from "../../common/catalog/has-category-for-entity.injectable";
import { CatalogEntityRegistry } from "./entity-registry";
const catalogEntityRegistryInjectable = getInjectable({
id: "catalog-entity-registry",
instantiate: (di) => new CatalogEntityRegistry({
hasCategoryForEntity: di.inject(hasCategoryForEntityInjectable),
}),
});
export default catalogEntityRegistryInjectable;
@@ -0,0 +1,54 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { action, computed, type IComputedValue, type IObservableArray, makeObservable, observable } from "mobx";
import type { CatalogEntity } from "../../common/catalog";
import type { HasCategoryForEntity } from "../../common/catalog/has-category-for-entity.injectable";
import { iter } from "../../common/utils";
interface Dependencies {
readonly hasCategoryForEntity: HasCategoryForEntity;
}
export class CatalogEntityRegistry {
protected sources = observable.map<string, IComputedValue<CatalogEntity[]>>();
constructor(protected readonly dependencies: Dependencies) {
makeObservable(this);
}
@action addObservableSource(id: string, source: IObservableArray<CatalogEntity>) {
this.sources.set(id, computed(() => source));
}
@action addComputedSource(id: string, source: IComputedValue<CatalogEntity[]>) {
this.sources.set(id, source);
}
@action removeSource(id: string) {
this.sources.delete(id);
}
@computed get items(): CatalogEntity[] {
return Array.from(
iter.filter(
iter.flatMap(this.sources.values(), source => source.get()),
entity => this.dependencies.hasCategoryForEntity(entity),
),
);
}
findById(id: string): CatalogEntity | undefined {
return this.items.find(entity => entity.getId() === id);
}
filterItemsForApiKind(apiVersion: string, kind: string): CatalogEntity[] {
return this.items.filter((item) => item.apiVersion === apiVersion && item.kind === kind);
}
filterItemsByPredicate<E extends CatalogEntity>(filter: (item: CatalogEntity) => item is E): E[] {
return this.items.filter(filter);
}
}
+6
View File
@@ -0,0 +1,6 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
export * from "./entity-registry";
@@ -0,0 +1,17 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { spawn } from "child_process";
export type Spawn = typeof spawn;
const spawnInjectable = getInjectable({
id: "spawn",
instantiate: (): Spawn => spawn,
causesSideEffects: true,
});
export default spawnInjectable;
@@ -0,0 +1,141 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { clusterMetadataDetectorInjectionToken } from "./token";
import { ClusterMetadataKey } from "../../common/cluster-types";
import { getInjectable } from "@ogre-tools/injectable";
import k8SRequestInjectable from "../k8s-request.injectable";
import type { Cluster } from "../../common/cluster/cluster";
import requestClusterVersionInjectable from "./request-cluster-version.injectable";
const isGKE = (version: string) => version.includes("gke");
const isEKS = (version: string) => version.includes("eks");
const isIKS = (version: string) => version.includes("IKS");
const isAKS = (cluster: Cluster) => cluster.apiUrl.includes("azmk8s.io");
const isMirantis = (version: string) => version.includes("-mirantis-") || version.includes("-docker-");
const isDigitalOcean = (cluster: Cluster) => cluster.apiUrl.endsWith("k8s.ondigitalocean.com");
const isMinikube = (cluster: Cluster) => cluster.contextName.startsWith("minikube");
const isMicrok8s = (cluster: Cluster) => cluster.contextName.startsWith("microk8s");
const isKind = (cluster: Cluster) => cluster.contextName.startsWith("kubernetes-admin@kind-");
const isDockerDesktop = (cluster: Cluster) => cluster.contextName === "docker-desktop";
const isTke = (version: string) => version.includes("-tke.");
const isCustom = (version: string) => version.includes("+");
const isVMWare = (version: string) => version.includes("+vmware");
const isRke = (version: string) => version.includes("-rancher");
const isRancherDesktop = (cluster: Cluster) => cluster.contextName === "rancher-desktop";
const isK3s = (version: string) => version.includes("+k3s");
const isK0s = (version: string) => version.includes("-k0s") || version.includes("+k0s");
const isAlibaba = (version: string) => version.includes("-aliyun");
const isHuawei = (version: string) => version.includes("-CCE");
const clusterDistributionDetectorInjectable = getInjectable({
id: "cluster-distribution-detector",
instantiate: (di) => {
const k8sRequest = di.inject(k8SRequestInjectable);
const requestClusterVersion = di.inject(requestClusterVersionInjectable);
const isOpenshift = async (cluster: Cluster) => {
try {
const { paths = [] } = await k8sRequest(cluster, "") as { paths?: string[] };
return paths.includes("/apis/project.openshift.io");
} catch (e) {
return false;
}
};
return {
key: ClusterMetadataKey.DISTRIBUTION,
detect: async (cluster) => {
const version = await requestClusterVersion(cluster);
if (isRke(version)) {
return { value: "rke", accuracy: 80 };
}
if (isRancherDesktop(cluster)) {
return { value: "rancher-desktop", accuracy: 80 };
}
if (isK3s(version)) {
return { value: "k3s", accuracy: 80 };
}
if (isGKE(version)) {
return { value: "gke", accuracy: 80 };
}
if (isEKS(version)) {
return { value: "eks", accuracy: 80 };
}
if (isIKS(version)) {
return { value: "iks", accuracy: 80 };
}
if (isAKS(cluster)) {
return { value: "aks", accuracy: 80 };
}
if (isDigitalOcean(cluster)) {
return { value: "digitalocean", accuracy: 90 };
}
if (isK0s(version)) {
return { value: "k0s", accuracy: 80 };
}
if (isVMWare(version)) {
return { value: "vmware", accuracy: 90 };
}
if (isMirantis(version)) {
return { value: "mirantis", accuracy: 90 };
}
if (isAlibaba(version)) {
return { value: "alibaba", accuracy: 90 };
}
if (isHuawei(version)) {
return { value: "huawei", accuracy: 90 };
}
if (isTke(version)) {
return { value: "tencent", accuracy: 90 };
}
if (isMinikube(cluster)) {
return { value: "minikube", accuracy: 80 };
}
if (isMicrok8s(cluster)) {
return { value: "microk8s", accuracy: 80 };
}
if (isKind(cluster)) {
return { value: "kind", accuracy: 70 };
}
if (isDockerDesktop(cluster)) {
return { value: "docker-desktop", accuracy: 80 };
}
if (isCustom(version)) {
if (await isOpenshift(cluster)) {
return { value: "openshift", accuracy: 90 };
}
return { value: "custom", accuracy: 10 };
}
return { value: "unknown", accuracy: 10 };
},
};
},
injectionToken: clusterMetadataDetectorInjectionToken,
});
export default clusterDistributionDetectorInjectable;
@@ -0,0 +1,43 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { clusterMetadataDetectorInjectionToken } from "./token";
import { createHash } from "crypto";
import { ClusterMetadataKey } from "../../common/cluster-types";
import { getInjectable } from "@ogre-tools/injectable";
import k8SRequestInjectable from "../k8s-request.injectable";
import type { Cluster } from "../../common/cluster/cluster";
const clusterIdDetectorFactoryInjectable = getInjectable({
id: "cluster-id-detector-factory",
instantiate: (di) => {
const k8sRequest = di.inject(k8SRequestInjectable);
const getDefaultNamespaceId = async (cluster: Cluster) => {
const { metadata } = await k8sRequest(cluster, "/api/v1/namespaces/default") as { metadata: { uid: string }};
return metadata.uid;
};
return {
key: ClusterMetadataKey.CLUSTER_ID,
detect: async (cluster) => {
let id: string;
try {
id = await getDefaultNamespaceId(cluster);
} catch(_) {
id = cluster.apiUrl;
}
const value = createHash("sha256").update(id).digest("hex");
return { value, accuracy: 100 };
},
};
},
injectionToken: clusterMetadataDetectorInjectionToken,
});
export default clusterIdDetectorFactoryInjectable;
@@ -0,0 +1,32 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { clusterMetadataDetectorInjectionToken } from "./token";
import { ClusterMetadataKey } from "../../common/cluster-types";
import { getInjectable } from "@ogre-tools/injectable";
import requestClusterVersionInjectable from "./request-cluster-version.injectable";
const clusterLastSeenDetectorInjectable = getInjectable({
id: "cluster-last-seen-detector",
instantiate: (di) => {
const requestClusterVersion = di.inject(requestClusterVersionInjectable);
return {
key: ClusterMetadataKey.LAST_SEEN,
detect: async (cluster) => {
try {
await requestClusterVersion(cluster);
return { value: new Date().toJSON(), accuracy: 100 };
} catch {
return null;
}
},
};
},
injectionToken: clusterMetadataDetectorInjectionToken,
});
export default clusterLastSeenDetectorInjectable;
@@ -0,0 +1,40 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { clusterMetadataDetectorInjectionToken } from "./token";
import { ClusterMetadataKey } from "../../common/cluster-types";
import { getInjectable } from "@ogre-tools/injectable";
import k8SRequestInjectable from "../k8s-request.injectable";
import type { Cluster } from "../../common/cluster/cluster";
const clusterNodeCountDetectorInjectable = getInjectable({
id: "cluster-node-count-detector",
instantiate: (di) => {
const k8sRequest = di.inject(k8SRequestInjectable);
const requestNodeCount = async (cluster: Cluster) => {
const { items } = await k8sRequest(cluster, "/api/v1/nodes") as { items: unknown[] };
return items.length;
};
return {
key: ClusterMetadataKey.NODES_COUNT,
detect: async (cluster) => {
try {
return {
value: await requestNodeCount(cluster),
accuracy: 1000,
};
} catch {
return null;
}
},
};
},
injectionToken: clusterMetadataDetectorInjectionToken,
});
export default clusterNodeCountDetectorInjectable;
@@ -0,0 +1,28 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { clusterMetadataDetectorInjectionToken } from "./token";
import { ClusterMetadataKey } from "../../common/cluster-types";
import { getInjectable } from "@ogre-tools/injectable";
import requestClusterVersionInjectable from "./request-cluster-version.injectable";
const clusterVersionDetectorInjectable = getInjectable({
id: "cluster-version-detector",
instantiate: (di) => {
const requestClusterVersion = di.inject(requestClusterVersionInjectable);
return {
key: ClusterMetadataKey.VERSION,
detect: async (cluster) => ({
value: await requestClusterVersion(cluster),
accuracy: 100,
}),
};
},
injectionToken: clusterMetadataDetectorInjectionToken,
});
export default clusterVersionDetectorInjectable;
@@ -0,0 +1,51 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { pipeline } from "@ogre-tools/fp";
import { getInjectable } from "@ogre-tools/injectable";
import { groupBy, reduce } from "lodash";
import { filter, map } from "lodash/fp";
import type { ClusterMetadata } from "../../common/cluster-types";
import type { Cluster } from "../../common/cluster/cluster";
import { hasDefinedTupleValue, isDefined, object } from "../../common/utils";
import type { ClusterDetectionResult, ClusterMetadataDetector } from "./token";
import { clusterMetadataDetectorInjectionToken } from "./token";
export type DetectClusterMetadata = (cluster: Cluster) => Promise<ClusterMetadata>;
const pickHighestAccuracy = (prev: ClusterDetectionResult, curr: ClusterDetectionResult) => (
prev.accuracy > curr.accuracy
? prev
: curr
);
const detectMetadataWithFor = (cluster: Cluster) => async (clusterMetadataDetector: ClusterMetadataDetector) => {
try {
return await clusterMetadataDetector.detect(cluster);
} catch {
return null;
}
};
const detectClusterMetadataInjectable = getInjectable({
id: "detect-cluster-metadata",
instantiate: (di): DetectClusterMetadata => {
const clusterMetadataDetectors = di.injectMany(clusterMetadataDetectorInjectionToken);
return async (cluster) => {
const entries = pipeline(
await Promise.all(clusterMetadataDetectors.map(detectMetadataWithFor(cluster))),
filter(isDefined),
(arg) => groupBy(arg, "key"),
(arg) => object.entries(arg),
map(([ key, results ]) => [key, reduce(results, pickHighestAccuracy)] as const),
filter(hasDefinedTupleValue),
);
return object.fromEntries(entries);
};
},
});
export default detectClusterMetadataInjectable;
@@ -0,0 +1,22 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import type { Cluster } from "../../common/cluster/cluster";
import k8SRequestInjectable from "../k8s-request.injectable";
const requestClusterVersionInjectable = getInjectable({
id: "request-cluster-version",
instantiate: (di) => {
const k8sRequest = di.inject(k8SRequestInjectable);
return async (cluster: Cluster) => {
const { gitVersion } = await k8sRequest(cluster, "/version") as { gitVersion: string };
return gitVersion;
};
},
});
export default requestClusterVersionInjectable;
@@ -0,0 +1,26 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectionToken } from "@ogre-tools/injectable";
import type { Cluster } from "../../common/cluster/cluster";
export interface ClusterDetectionResult {
value: string | number | boolean;
accuracy: number;
}
export interface FalibleOnlyClusterMetadataDetector {
readonly key: string;
detect(cluster: Cluster): Promise<ClusterDetectionResult>;
}
export interface ClusterMetadataDetector {
readonly key: string;
detect(cluster: Cluster): Promise<ClusterDetectionResult | null>;
}
export const clusterMetadataDetectorInjectionToken = getInjectionToken<ClusterMetadataDetector>({
id: "cluster-metadata-detector-token",
});
@@ -0,0 +1,14 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { observable } from "mobx";
import type { ClusterId } from "../../common/cluster-types";
const clustersThatAreBeingDeletedInjectable = getInjectable({
id: "clusters-that-are-being-deleted",
instantiate: () => observable.set<ClusterId>(),
});
export default clustersThatAreBeingDeletedInjectable;
@@ -0,0 +1,25 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { onLoadOfApplicationInjectionToken } from "../start-main-application/runnable-tokens/on-load-of-application-injection-token";
import clusterManagerInjectable from "./manager.injectable";
const initializeClusterManagerInjectable = getInjectable({
id: "initialize-cluster-manager",
instantiate: (di) => {
const clusterManager = di.inject(clusterManagerInjectable);
return {
id: "initialize-cluster-manager",
run: () => {
clusterManager.init();
},
};
},
injectionToken: onLoadOfApplicationInjectionToken,
causesSideEffects: true,
});
export default initializeClusterManagerInjectable;
@@ -0,0 +1,25 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import clusterStoreInjectable from "../../common/cluster-store/cluster-store.injectable";
import loggerInjectable from "../../common/logger.injectable";
import catalogEntityRegistryInjectable from "../catalog/entity-registry.injectable";
import clustersThatAreBeingDeletedInjectable from "./are-being-deleted.injectable";
import { ClusterManager } from "./manager";
import visibleClusterInjectable from "./visible-cluster.injectable";
const clusterManagerInjectable = getInjectable({
id: "cluster-manager",
instantiate: (di) => new ClusterManager({
store: di.inject(clusterStoreInjectable),
catalogEntityRegistry: di.inject(catalogEntityRegistryInjectable),
clustersThatAreBeingDeleted: di.inject(clustersThatAreBeingDeletedInjectable),
visibleCluster: di.inject(visibleClusterInjectable),
logger: di.inject(loggerInjectable),
}),
});
export default clusterManagerInjectable;
+289
View File
@@ -0,0 +1,289 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import "../../common/ipc/cluster";
import type { IObservableValue, ObservableSet } from "mobx";
import { action, makeObservable, observe, reaction, toJS } from "mobx";
import type { Cluster } from "../../common/cluster/cluster";
import { isErrnoException } from "../../common/utils";
import type { KubernetesClusterPrometheusMetrics } from "../../common/catalog-entities/kubernetes-cluster";
import { isKubernetesCluster, KubernetesCluster, LensKubernetesClusterStatus } from "../../common/catalog-entities/kubernetes-cluster";
import { ipcMainOn } from "../../common/ipc";
import { once } from "lodash";
import type { ClusterStore } from "../../common/cluster-store/cluster-store";
import type { ClusterId } from "../../common/cluster-types";
import type { CatalogEntityRegistry } from "../catalog";
import type { Logger } from "../../common/logger";
const logPrefix = "[CLUSTER-MANAGER]:";
const lensSpecificClusterStatuses: Set<string> = new Set(Object.values(LensKubernetesClusterStatus));
interface Dependencies {
readonly store: ClusterStore;
readonly catalogEntityRegistry: CatalogEntityRegistry;
readonly clustersThatAreBeingDeleted: ObservableSet<ClusterId>;
readonly visibleCluster: IObservableValue<ClusterId | null>;
readonly logger: Logger;
}
export class ClusterManager {
constructor(private readonly dependencies: Dependencies) {
makeObservable(this);
}
init = once(() => {
// reacting to every cluster's state change and total amount of items
reaction(
() => this.dependencies.store.clustersList.map(c => c.getState()),
() => this.updateCatalog(this.dependencies.store.clustersList),
{ fireImmediately: false },
);
// reacting to every cluster's preferences change and total amount of items
reaction(
() => this.dependencies.store.clustersList.map(c => toJS(c.preferences)),
() => this.updateCatalog(this.dependencies.store.clustersList),
{ fireImmediately: false },
);
reaction(
() => this.dependencies.catalogEntityRegistry.filterItemsByPredicate(isKubernetesCluster),
entities => this.syncClustersFromCatalog(entities),
);
reaction(() => [
this.dependencies.catalogEntityRegistry.filterItemsByPredicate(isKubernetesCluster),
this.dependencies.visibleCluster.get(),
] as const, ([entities, visibleCluster]) => {
for (const entity of entities) {
if (entity.getId() === visibleCluster) {
entity.status.active = true;
} else {
entity.status.active = false;
}
}
});
observe(this.dependencies.clustersThatAreBeingDeleted, change => {
if (change.type === "add") {
this.updateEntityStatus(this.dependencies.catalogEntityRegistry.findById(change.newValue) as KubernetesCluster);
}
});
ipcMainOn("network:offline", this.onNetworkOffline);
ipcMainOn("network:online", this.onNetworkOnline);
});
@action
protected updateCatalog(clusters: Cluster[]) {
this.dependencies.logger.debug("[CLUSTER-MANAGER]: updating catalog from cluster store");
for (const cluster of clusters) {
this.updateEntityFromCluster(cluster);
}
}
protected updateEntityFromCluster(cluster: Cluster) {
const index = this.dependencies.catalogEntityRegistry.items.findIndex((entity) => entity.getId() === cluster.id);
if (index === -1) {
return;
}
const entity = this.dependencies.catalogEntityRegistry.items[index] as KubernetesCluster;
this.updateEntityStatus(entity, cluster);
entity.metadata.labels = {
...entity.metadata.labels,
...cluster.labels,
};
entity.metadata.distro = cluster.distribution;
entity.metadata.kubeVersion = cluster.version;
if (cluster.preferences?.clusterName) {
/**
* Only set the name if the it is overriden in preferences. If it isn't
* set then the name of the entity has been explicitly set by its source
*/
entity.metadata.name = cluster.preferences.clusterName;
}
entity.spec.metrics ||= { source: "local" };
if (entity.spec.metrics.source === "local") {
const prometheus: KubernetesClusterPrometheusMetrics = entity.spec?.metrics?.prometheus || {};
prometheus.type = cluster.preferences.prometheusProvider?.type;
prometheus.address = cluster.preferences.prometheus;
entity.spec.metrics.prometheus = prometheus;
}
if (cluster.preferences.icon) {
entity.spec.icon ??= {};
entity.spec.icon.src = cluster.preferences.icon;
} else if (cluster.preferences.icon === null) {
/**
* NOTE: only clear the icon if set to `null` by ClusterIconSettings.
* We can then also clear that value too
*/
entity.spec.icon = undefined;
cluster.preferences.icon = undefined;
}
this.dependencies.catalogEntityRegistry.items.splice(index, 1, entity);
}
@action
protected updateEntityStatus(entity: KubernetesCluster, cluster?: Cluster) {
if (this.dependencies.clustersThatAreBeingDeleted.has(entity.getId())) {
entity.status.phase = LensKubernetesClusterStatus.DELETING;
entity.status.enabled = false;
} else {
entity.status.phase = (() => {
if (!cluster) {
this.dependencies.logger.silly(`${logPrefix} setting entity ${entity.getName()} to DISCONNECTED, reason="no cluster"`);
return LensKubernetesClusterStatus.DISCONNECTED;
}
if (cluster.accessible) {
this.dependencies.logger.silly(`${logPrefix} setting entity ${entity.getName()} to CONNECTED, reason="cluster is accessible"`);
return LensKubernetesClusterStatus.CONNECTED;
}
if (!cluster.disconnected) {
this.dependencies.logger.silly(`${logPrefix} setting entity ${entity.getName()} to CONNECTING, reason="cluster is not disconnected"`);
return LensKubernetesClusterStatus.CONNECTING;
}
// Extensions are not allowed to use the Lens specific status phases
if (!lensSpecificClusterStatuses.has(entity?.status?.phase)) {
this.dependencies.logger.silly(`${logPrefix} not clearing entity ${entity.getName()} status, reason="custom string"`);
return entity.status.phase;
}
this.dependencies.logger.silly(`${logPrefix} setting entity ${entity.getName()} to DISCONNECTED, reason="fallthrough"`);
return LensKubernetesClusterStatus.DISCONNECTED;
})();
entity.status.enabled = true;
}
}
@action
protected syncClustersFromCatalog(entities: KubernetesCluster[]) {
for (const entity of entities) {
const cluster = this.dependencies.store.getById(entity.getId());
if (!cluster) {
const model = {
id: entity.getId(),
kubeConfigPath: entity.spec.kubeconfigPath,
contextName: entity.spec.kubeconfigContext,
accessibleNamespaces: entity.spec.accessibleNamespaces ?? [],
};
try {
/**
* Add the bare minimum of data to ClusterStore. And especially no
* preferences, as those might be configured by the entity's source
*/
this.dependencies.store.addCluster(model);
} catch (error) {
if (isErrnoException(error) && error.code === "ENOENT" && error.path === entity.spec.kubeconfigPath) {
this.dependencies.logger.warn(`${logPrefix} kubeconfig file disappeared`, model);
} else {
this.dependencies.logger.error(`${logPrefix} failed to add cluster: ${error}`, model);
}
}
} else {
cluster.kubeConfigPath = entity.spec.kubeconfigPath;
cluster.contextName = entity.spec.kubeconfigContext;
if (entity.spec.accessibleNamespaces) {
cluster.accessibleNamespaces.replace(entity.spec.accessibleNamespaces);
}
if (entity.spec.metrics) {
const { source, prometheus } = entity.spec.metrics;
if (source !== "local" && prometheus) {
const { type, address } = prometheus;
if (type) {
cluster.preferences.prometheusProvider = { type };
}
if (address) {
cluster.preferences.prometheus = address;
}
}
}
this.updateEntityFromCluster(cluster);
}
}
}
protected onNetworkOffline = () => {
this.dependencies.logger.info(`${logPrefix} network is offline`);
this.dependencies.store.clustersList.forEach((cluster) => {
if (!cluster.disconnected) {
cluster.online = false;
cluster.accessible = false;
cluster.refreshConnectionStatus().catch((e) => e);
}
});
};
protected onNetworkOnline = () => {
this.dependencies.logger.info(`${logPrefix} network is online`);
this.dependencies.store.clustersList.forEach((cluster) => {
if (!cluster.disconnected) {
cluster.refreshConnectionStatus().catch((e) => e);
}
});
};
stop() {
this.dependencies.store.clusters.forEach((cluster: Cluster) => {
cluster.disconnect();
});
}
}
export function catalogEntityFromCluster(cluster: Cluster) {
return new KubernetesCluster({
metadata: {
uid: cluster.id,
name: cluster.name,
source: "local",
labels: {
...cluster.labels,
},
distro: cluster.distribution,
kubeVersion: cluster.version,
},
spec: {
kubeconfigPath: cluster.kubeConfigPath,
kubeconfigContext: cluster.contextName,
icon: {},
},
status: {
phase: cluster.disconnected
? LensKubernetesClusterStatus.DISCONNECTED
: LensKubernetesClusterStatus.CONNECTED,
reason: "",
message: "",
active: !cluster.disconnected,
},
});
}
@@ -0,0 +1,49 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import loggerInjectable from "../../common/logger.injectable";
import type { KubeApiResource } from "../../common/rbac";
import type { Cluster } from "../../common/cluster/cluster";
import { requestApiVersionsInjectionToken } from "./request-api-versions";
import { withConcurrencyLimit } from "../../common/utils/with-concurrency-limit";
import requestKubeApiResourcesForInjectable from "./request-kube-api-resources-for.injectable";
export type RequestApiResources = (cluster: Cluster) => Promise<KubeApiResource[]>;
export interface KubeResourceListGroup {
group: string;
path: string;
}
const requestApiResourcesInjectable = getInjectable({
id: "request-api-resources",
instantiate: (di): RequestApiResources => {
const logger = di.inject(loggerInjectable);
const apiVersionRequesters = di.injectMany(requestApiVersionsInjectionToken);
const requestKubeApiResourcesFor = di.inject(requestKubeApiResourcesForInjectable);
return async (cluster) => {
const requestKubeApiResources = withConcurrencyLimit(5)(requestKubeApiResourcesFor(cluster));
try {
const requests = await Promise.all(apiVersionRequesters.map(fn => fn(cluster)));
const resources = await Promise.all((
requests
.flat()
.map(requestKubeApiResources)
));
return resources.flat();
} catch (error) {
logger.error(`[LIST-API-RESOURCES]: failed to list api resources: ${error}`);
return [];
}
};
},
});
export default requestApiResourcesInjectable;
@@ -0,0 +1,18 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectionToken } from "@ogre-tools/injectable";
import type { Cluster } from "../../common/cluster/cluster";
export interface KubeResourceListGroup {
group: string;
path: string;
}
export type RequestApiVersions = (cluster: Cluster) => Promise<KubeResourceListGroup[]>;
export const requestApiVersionsInjectionToken = getInjectionToken<RequestApiVersions>({
id: "request-api-versions-token",
});
@@ -0,0 +1,27 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { V1APIVersions } from "@kubernetes/client-node";
import { getInjectable } from "@ogre-tools/injectable";
import k8sRequestInjectable from "../k8s-request.injectable";
import { requestApiVersionsInjectionToken } from "./request-api-versions";
const requestCoreApiVersionsInjectable = getInjectable({
id: "request-core-api-versions",
instantiate: (di) => {
const k8sRequest = di.inject(k8sRequestInjectable);
return async (cluster) => {
const { versions } = await k8sRequest(cluster, "/api") as V1APIVersions;
return versions.map(version => ({
group: "",
path: `/api/${version}`,
}));
};
},
injectionToken: requestApiVersionsInjectionToken,
});
export default requestCoreApiVersionsInjectable;
@@ -0,0 +1,34 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { V1APIResourceList } from "@kubernetes/client-node";
import { getInjectable } from "@ogre-tools/injectable";
import type { Cluster } from "../../common/cluster/cluster";
import type { KubeApiResource } from "../../common/rbac";
import k8sRequestInjectable from "../k8s-request.injectable";
import type { KubeResourceListGroup } from "./request-api-versions";
export type RequestKubeApiResources = (grouping: KubeResourceListGroup) => Promise<KubeApiResource[]>;
export type RequestKubeApiResourcesFor = (cluster: Cluster) => RequestKubeApiResources;
const requestKubeApiResourcesForInjectable = getInjectable({
id: "request-kube-api-resources-for",
instantiate: (di): RequestKubeApiResourcesFor => {
const k8sRequest = di.inject(k8sRequestInjectable);
return (cluster) => async ({ group, path }) => {
const { resources } = await k8sRequest(cluster, path) as V1APIResourceList;
return resources.map(resource => ({
apiName: resource.name,
kind: resource.kind,
group,
namespaced: resource.namespaced,
}));
};
},
});
export default requestKubeApiResourcesForInjectable;
@@ -0,0 +1,30 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { V1APIGroupList } from "@kubernetes/client-node";
import { getInjectable } from "@ogre-tools/injectable";
import { chain } from "../../common/utils/iter";
import k8sRequestInjectable from "../k8s-request.injectable";
import { requestApiVersionsInjectionToken } from "./request-api-versions";
const requestNonCoreApiVersionsInjectable = getInjectable({
id: "request-non-core-api-versions",
instantiate: (di) => {
const k8sRequest = di.inject(k8sRequestInjectable);
return async (cluster) => {
const { groups } = await k8sRequest(cluster, "/apis") as V1APIGroupList;
return chain(groups.values())
.filterMap(group => group.preferredVersion?.groupVersion && ({
group: group.name,
path: `/apis/${group.preferredVersion.groupVersion}`,
}))
.collect(v => [...v]);
};
},
injectionToken: requestApiVersionsInjectionToken,
});
export default requestNonCoreApiVersionsInjectable;
@@ -0,0 +1,98 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
// Move embedded kubeconfig into separate file and add reference to it to cluster settings
// convert file path cluster icons to their base64 encoded versions
import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
import getCustomKubeConfigFilePathInjectable from "../../../common/app-paths/get-custom-kube-config-directory/get-custom-kube-config-directory.injectable";
import type { ClusterModel } from "../../../common/cluster-types";
import readFileSyncInjectable from "../../../common/fs/read-file-sync.injectable";
import { loadConfigFromString } from "../../../common/kube-helpers";
import joinPathsInjectable from "../../../common/path/join-paths.injectable";
interface Pre360ClusterModel extends ClusterModel {
kubeConfig?: string;
}
import { getInjectable } from "@ogre-tools/injectable";
import { clusterStoreMigrationInjectionToken } from "../../../common/cluster-store/migration-token";
import readFileBufferSyncInjectable from "../../../common/fs/read-file-buffer-sync.injectable";
import loggerInjectable from "../../../common/logger.injectable";
import writeFileSyncInjectable from "../../../common/fs/write-file-sync.injectable";
const v360Beta1ClusterStoreMigrationInjectable = getInjectable({
id: "v3.6.0-beta.1-cluster-store-migration",
instantiate: (di) => {
const userDataPath = di.inject(directoryForUserDataInjectable);
const getCustomKubeConfigDirectory = di.inject(getCustomKubeConfigFilePathInjectable);
const readFileSync = di.inject(readFileSyncInjectable);
const readFileBufferSync = di.inject(readFileBufferSyncInjectable);
const joinPaths = di.inject(joinPathsInjectable);
const logger = di.inject(loggerInjectable);
const writeFileSync = di.inject(writeFileSyncInjectable);
return {
version: "3.6.0-beta.1",
run: (store) => {
const storedClusters = (store.get("clusters") ?? []) as Pre360ClusterModel[];
const migratedClusters: ClusterModel[] = [];
logger.info("Number of clusters to migrate: ", storedClusters.length);
for (const clusterModel of storedClusters) {
/**
* migrate kubeconfig
*/
try {
const absPath = getCustomKubeConfigDirectory(clusterModel.id);
if (!clusterModel.kubeConfig) {
continue;
}
// take the embedded kubeconfig and dump it into a file
writeFileSync(absPath, clusterModel.kubeConfig);
clusterModel.kubeConfigPath = absPath;
clusterModel.contextName = loadConfigFromString(readFileSync(absPath)).config.getCurrentContext();
delete clusterModel.kubeConfig;
} catch (error) {
logger.info(`Failed to migrate Kubeconfig for cluster "${clusterModel.id}", removing clusterModel...`, error);
continue;
}
/**
* migrate cluster icon
*/
try {
if (clusterModel.preferences?.icon) {
logger.info(`migrating ${clusterModel.preferences.icon} for ${clusterModel.preferences.clusterName}`);
const iconPath = clusterModel.preferences.icon.replace("store://", "");
const fileData = readFileBufferSync(joinPaths(userDataPath, iconPath));
clusterModel.preferences.icon = `data:;base64,${fileData.toString("base64")}`;
} else {
delete clusterModel.preferences?.icon;
}
} catch (error) {
console.log(error);
logger.info(`Failed to migrate cluster icon for cluster "${clusterModel.id}"`, error);
delete clusterModel.preferences?.icon;
}
migratedClusters.push(clusterModel);
}
store.set("clusters", migratedClusters);
},
};
},
injectionToken: clusterStoreMigrationInjectionToken,
});
export default v360Beta1ClusterStoreMigrationInjectable;
@@ -0,0 +1,70 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
import { clusterStoreMigrationInjectionToken } from "../../../common/cluster-store/migration-token";
import type { ClusterModel } from "../../../common/cluster-types";
import readJsonSyncInjectable from "../../../common/fs/read-json-sync.injectable";
import joinPathsInjectable from "../../../common/path/join-paths.injectable";
import { isErrnoException } from "../../../common/utils";
interface Pre500WorkspaceStoreModel {
workspaces: {
id: string;
name: string;
}[];
}
const v500Beta10ClusterStoreMigrationInjectable = getInjectable({
id: "v5.0.0-beta.10-cluster-store-migration",
instantiate: (di) => {
const userDataPath = di.inject(directoryForUserDataInjectable);
const joinPaths = di.inject(joinPathsInjectable);
const readJsonSync = di.inject(readJsonSyncInjectable);
return {
version: "5.0.0-beta.10",
run(store) {
try {
const workspaceData: Pre500WorkspaceStoreModel = readJsonSync(joinPaths(userDataPath, "lens-workspace-store.json"));
const workspaces = new Map<string, string>(); // mapping from WorkspaceId to name
for (const { id, name } of workspaceData.workspaces) {
workspaces.set(id, name);
}
const clusters = (store.get("clusters") ?? []) as ClusterModel[];
for (const cluster of clusters) {
if (cluster.workspace) {
const workspace = workspaces.get(cluster.workspace);
if (workspace) {
(cluster.labels ??= {}).workspace = workspace;
}
}
}
store.set("clusters", clusters);
} catch (error) {
// KLUDGE: remove after https://github.com/streamich/memfs/pull/893 is released
if (process.env.JEST_WORKER_ID && (error as any).code === "ENOENT") {
return;
}
if (isErrnoException(error) && error.code === "ENOENT" && error.path?.endsWith("lens-workspace-store.json")) {
// ignore lens-workspace-store.json being missing
return;
}
throw error;
}
},
};
},
injectionToken: clusterStoreMigrationInjectionToken,
});
export default v500Beta10ClusterStoreMigrationInjectable;
@@ -0,0 +1,135 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { ClusterModel, ClusterPreferences, ClusterPrometheusPreferences } from "../../../common/cluster-types";
import { moveSync, removeSync } from "fs-extra";
import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
import { isDefined } from "../../../common/utils";
import joinPathsInjectable from "../../../common/path/join-paths.injectable";
function mergePrometheusPreferences(left: ClusterPrometheusPreferences, right: ClusterPrometheusPreferences): ClusterPrometheusPreferences {
if (left.prometheus && left.prometheusProvider) {
return {
prometheus: left.prometheus,
prometheusProvider: left.prometheusProvider,
};
}
if (right.prometheus && right.prometheusProvider) {
return {
prometheus: right.prometheus,
prometheusProvider: right.prometheusProvider,
};
}
return {};
}
function mergePreferences(left: ClusterPreferences, right: ClusterPreferences): ClusterPreferences {
return {
terminalCWD: left.terminalCWD || right.terminalCWD || undefined,
clusterName: left.clusterName || right.clusterName || undefined,
iconOrder: left.iconOrder || right.iconOrder || undefined,
icon: left.icon || right.icon || undefined,
httpsProxy: left.httpsProxy || right.httpsProxy || undefined,
hiddenMetrics: mergeSet(left.hiddenMetrics ?? [], right.hiddenMetrics ?? []),
...mergePrometheusPreferences(left, right),
};
}
function mergeLabels(left: Record<string, string>, right: Record<string, string>): Record<string, string> {
return {
...right,
...left,
};
}
function mergeSet(...iterables: Iterable<string | undefined>[]): string[] {
const res = new Set<string>();
for (const iterable of iterables) {
for (const val of iterable) {
if (val) {
res.add(val);
}
}
}
return [...res];
}
function mergeClusterModel(prev: ClusterModel, right: Omit<ClusterModel, "id">): ClusterModel {
return {
id: prev.id,
kubeConfigPath: prev.kubeConfigPath,
contextName: prev.contextName,
preferences: mergePreferences(prev.preferences ?? {}, right.preferences ?? {}),
metadata: prev.metadata,
labels: mergeLabels(prev.labels ?? {}, right.labels ?? {}),
accessibleNamespaces: mergeSet(prev.accessibleNamespaces ?? [], right.accessibleNamespaces ?? []),
workspace: prev.workspace || right.workspace,
workspaces: mergeSet([prev.workspace, right.workspace], prev.workspaces ?? [], right.workspaces ?? []),
};
}
import { getInjectable } from "@ogre-tools/injectable";
import loggerInjectable from "../../../common/logger.injectable";
import { clusterStoreMigrationInjectionToken } from "../../../common/cluster-store/migration-token";
import { generateNewIdFor } from "../../../common/utils/generate-new-id-for";
const v500Beta13ClusterStoreMigrationInjectable = getInjectable({
id: "v5.0.0-beta.13-cluster-store-migration",
instantiate: (di) => {
const userDataPath = di.inject(directoryForUserDataInjectable);
const joinPaths = di.inject(joinPathsInjectable);
const logger = di.inject(loggerInjectable);
const moveStorageFolder = ({ folder, newId, oldId }: { folder: string; newId: string; oldId: string }): void => {
const oldPath = joinPaths(folder, `${oldId}.json`);
const newPath = joinPaths(folder, `${newId}.json`);
try {
moveSync(oldPath, newPath);
} catch (error) {
if (String(error).includes("dest already exists")) {
logger.info(`Multiple old lens-local-storage files for newId=${newId}. Removing ${oldId}.json`);
removeSync(oldPath);
}
}
};
return {
version: "5.0.0-beta.13",
run(store) {
const folder = joinPaths(userDataPath, "lens-local-storage");
const oldClusters = (store.get("clusters") ?? []) as ClusterModel[];
const clusters = new Map<string, ClusterModel>();
for (const { id: oldId, ...cluster } of oldClusters) {
const newId = generateNewIdFor(cluster);
const newCluster = clusters.get(newId);
if (newCluster) {
logger.info(`Duplicate entries for ${newId}`, { oldId });
clusters.set(newId, mergeClusterModel(newCluster, cluster));
} else {
logger.info(`First entry for ${newId}`, { oldId });
clusters.set(newId, {
...cluster,
id: newId,
workspaces: [cluster.workspace].filter(isDefined),
});
moveStorageFolder({ folder, newId, oldId });
}
}
store.set("clusters", [...clusters.values()]);
},
};
},
injectionToken: clusterStoreMigrationInjectionToken,
});
export default v500Beta13ClusterStoreMigrationInjectable;
@@ -0,0 +1,60 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
// Fix embedded kubeconfig paths under snap config
import { getInjectable } from "@ogre-tools/injectable";
import applicationInformationToken from "../../../common/vars/application-information-token";
import { clusterStoreMigrationInjectionToken } from "../../../common/cluster-store/migration-token";
import loggerInjectable from "../../../common/logger.injectable";
import isSnapPackageInjectable from "../../../common/vars/is-snap-package.injectable";
import type { ClusterModel } from "../../../common/cluster-types";
import pathExistsSyncInjectable from "../../../common/fs/path-exists-sync.injectable";
const clusterStoreSnapMigrationInjectable = getInjectable({
id: "cluster-store-snap-migration",
instantiate: (di) => {
const { version } = di.inject(applicationInformationToken);
const logger = di.inject(loggerInjectable);
const isSnapPackage = di.inject(isSnapPackageInjectable);
const pathExistsSync = di.inject(pathExistsSyncInjectable);
return {
version, // Run always after upgrade
run(store) {
if (!isSnapPackage) {
return;
}
logger.info("Migrating embedded kubeconfig paths");
const storedClusters = (store.get("clusters") || []) as ClusterModel[];
if (!storedClusters.length) return;
logger.info("Number of clusters to migrate: ", storedClusters.length);
const migratedClusters = storedClusters
.map(cluster => {
/**
* replace snap version with 'current' in kubeconfig path
*/
if (!pathExistsSync(cluster.kubeConfigPath)) {
const kubeconfigPath = cluster.kubeConfigPath.replace(/\/snap\/kontena-lens\/[0-9]*\//, "/snap/kontena-lens/current/");
cluster.kubeConfigPath = kubeconfigPath;
}
return cluster;
});
store.set("clusters", migratedClusters);
},
};
},
injectionToken: clusterStoreMigrationInjectionToken,
});
export default clusterStoreSnapMigrationInjectable;
@@ -0,0 +1,19 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { clusterVisibilityChannel } from "../../common/cluster/visibility-channel";
import { getMessageChannelListenerInjectable } from "../../common/utils/channel/message-channel-listener-injection-token";
import visibleClusterInjectable from "./visible-cluster.injectable";
const clusterVisibilityHandlerInjectable = getMessageChannelListenerInjectable({
channel: clusterVisibilityChannel,
id: "base",
handler: (di) => {
const visibleCluster = di.inject(visibleClusterInjectable);
return (clusterId) => visibleCluster.set(clusterId);
},
});
export default clusterVisibilityHandlerInjectable;
@@ -0,0 +1,14 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { observable } from "mobx";
import type { ClusterId } from "../../common/cluster-types";
const visibleClusterInjectable = getInjectable({
id: "visible-cluster",
instantiate: () => observable.box<ClusterId | null>(null),
});
export default visibleClusterInjectable;
@@ -0,0 +1,219 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { PrometheusProvider, PrometheusService } from "../prometheus/provider";
import type { ClusterPrometheusPreferences } from "../../common/cluster-types";
import type { Cluster } from "../../common/cluster/cluster";
import type httpProxy from "http-proxy";
import type { UrlWithStringQuery } from "url";
import url from "url";
import { CoreV1Api } from "@kubernetes/client-node";
import type { KubeAuthProxy } from "../kube-auth-proxy/kube-auth-proxy";
import type { CreateKubeAuthProxy } from "../kube-auth-proxy/create-kube-auth-proxy.injectable";
import type { GetPrometheusProviderByKind } from "../prometheus/get-by-kind.injectable";
import type { IComputedValue } from "mobx";
import type { Logger } from "../../common/logger";
export interface PrometheusDetails {
prometheusPath: string;
provider: PrometheusProvider;
}
interface PrometheusServicePreferences {
namespace: string;
service: string;
port: number;
prefix: string;
}
export interface ContextHandlerDependencies {
createKubeAuthProxy: CreateKubeAuthProxy;
getPrometheusProviderByKind: GetPrometheusProviderByKind;
readonly authProxyCa: string;
readonly prometheusProviders: IComputedValue<PrometheusProvider[]>;
readonly logger: Logger;
}
export interface ClusterContextHandler {
readonly clusterUrl: UrlWithStringQuery;
setupPrometheus(preferences?: ClusterPrometheusPreferences): void;
getPrometheusDetails(): Promise<PrometheusDetails>;
resolveAuthProxyUrl(): Promise<string>;
resolveAuthProxyCa(): string;
getApiTarget(isLongRunningRequest?: boolean): Promise<httpProxy.ServerOptions>;
restartServer(): Promise<void>;
ensureServer(): Promise<void>;
stopServer(): void;
}
export class ContextHandler implements ClusterContextHandler {
public readonly clusterUrl: UrlWithStringQuery;
protected kubeAuthProxy?: KubeAuthProxy;
protected apiTarget?: httpProxy.ServerOptions;
protected prometheusProvider?: string;
protected prometheus?: PrometheusServicePreferences;
constructor(private readonly dependencies: ContextHandlerDependencies, protected readonly cluster: Cluster) {
this.clusterUrl = url.parse(cluster.apiUrl);
this.setupPrometheus(cluster.preferences);
}
public setupPrometheus(preferences: ClusterPrometheusPreferences = {}) {
this.prometheusProvider = preferences.prometheusProvider?.type;
this.prometheus = preferences.prometheus;
}
public async getPrometheusDetails(): Promise<PrometheusDetails> {
const service = await this.getPrometheusService();
const prometheusPath = this.ensurePrometheusPath(service);
const provider = this.ensurePrometheusProvider(service);
return { prometheusPath, provider };
}
protected ensurePrometheusPath({ service, namespace, port }: PrometheusService): string {
return `${namespace}/services/${service}:${port}`;
}
protected ensurePrometheusProvider(service: PrometheusService): PrometheusProvider {
if (!this.prometheusProvider) {
this.dependencies.logger.info(`[CONTEXT-HANDLER]: using ${service.kind} as prometheus provider for clusterId=${this.cluster.id}`);
this.prometheusProvider = service.kind;
}
return this.dependencies.getPrometheusProviderByKind(this.prometheusProvider);
}
protected listPotentialProviders(): PrometheusProvider[] {
const provider = this.prometheusProvider && this.dependencies.getPrometheusProviderByKind(this.prometheusProvider);
if (provider) {
return [provider];
}
return this.dependencies.prometheusProviders.get();
}
protected async getPrometheusService(): Promise<PrometheusService> {
this.setupPrometheus(this.cluster.preferences);
if (this.prometheus && this.prometheusProvider) {
return {
kind: this.prometheusProvider,
namespace: this.prometheus.namespace,
service: this.prometheus.service,
port: this.prometheus.port,
};
}
const providers = this.listPotentialProviders();
const proxyConfig = await this.cluster.getProxyKubeconfig();
const apiClient = proxyConfig.makeApiClient(CoreV1Api);
const potentialServices = await Promise.allSettled(
providers.map(provider => provider.getPrometheusService(apiClient)),
);
const errors = [];
for (const res of potentialServices) {
switch (res.status) {
case "rejected":
errors.push(res.reason);
break;
case "fulfilled":
if (res.value) {
return res.value;
}
}
}
throw new Error("No Prometheus service found", { cause: errors });
}
async resolveAuthProxyUrl(): Promise<string> {
const kubeAuthProxy = await this.ensureServerHelper();
return `https://127.0.0.1:${kubeAuthProxy.port}${kubeAuthProxy.apiPrefix}`;
}
resolveAuthProxyCa() {
return this.dependencies.authProxyCa;
}
async getApiTarget(isLongRunningRequest = false): Promise<httpProxy.ServerOptions> {
const timeout = isLongRunningRequest ? 4 * 60 * 60_000 : 30_000; // 4 hours for long running request, 30 seconds for the rest
if (isLongRunningRequest) {
return this.newApiTarget(timeout);
}
return this.apiTarget ??= await this.newApiTarget(timeout);
}
protected async newApiTarget(timeout: number): Promise<httpProxy.ServerOptions> {
const kubeAuthProxy = await this.ensureServerHelper();
const headers: Record<string, string> = {};
if (this.clusterUrl.hostname) {
headers.Host = this.clusterUrl.hostname;
// fix current IPv6 inconsistency in url.Parse() and httpProxy.
// with url.Parse the IPv6 Hostname has no Square brackets but httpProxy needs the Square brackets to work.
if (headers.Host.includes(":")) {
headers.Host = `[${headers.Host}]`;
}
}
return {
target: {
protocol: "https:",
host: "127.0.0.1",
port: kubeAuthProxy.port,
path: kubeAuthProxy.apiPrefix,
ca: this.resolveAuthProxyCa(),
},
changeOrigin: true,
timeout,
secure: true,
headers,
};
}
protected async ensureServerHelper(): Promise<KubeAuthProxy> {
if (!this.kubeAuthProxy) {
const proxyEnv = Object.assign({}, process.env);
if (this.cluster.preferences.httpsProxy) {
proxyEnv.HTTPS_PROXY = this.cluster.preferences.httpsProxy;
}
this.kubeAuthProxy = this.dependencies.createKubeAuthProxy(this.cluster, proxyEnv);
await this.kubeAuthProxy.run();
return this.kubeAuthProxy;
}
await this.kubeAuthProxy.whenReady;
return this.kubeAuthProxy;
}
async ensureServer(): Promise<void> {
await this.ensureServerHelper();
}
async restartServer(): Promise<void> {
this.stopServer();
await this.ensureServerHelper();
}
stopServer() {
this.prometheus = undefined;
this.prometheusProvider = undefined;
this.kubeAuthProxy?.exit();
this.kubeAuthProxy = undefined;
this.apiTarget = undefined;
}
}
@@ -0,0 +1,38 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import type { Cluster } from "../../common/cluster/cluster";
import type { ClusterContextHandler, ContextHandlerDependencies } from "./context-handler";
import { ContextHandler } from "./context-handler";
import createKubeAuthProxyInjectable from "../kube-auth-proxy/create-kube-auth-proxy.injectable";
import kubeAuthProxyCertificateInjectable from "../kube-auth-proxy/kube-auth-proxy-certificate.injectable";
import URLParse from "url-parse";
import getPrometheusProviderByKindInjectable from "../prometheus/get-by-kind.injectable";
import prometheusProvidersInjectable from "../prometheus/providers.injectable";
import loggerInjectable from "../../common/logger.injectable";
const createContextHandlerInjectable = getInjectable({
id: "create-context-handler",
instantiate: (di) => {
const dependencies: Omit<ContextHandlerDependencies, "authProxyCa"> = {
createKubeAuthProxy: di.inject(createKubeAuthProxyInjectable),
getPrometheusProviderByKind: di.inject(getPrometheusProviderByKindInjectable),
prometheusProviders: di.inject(prometheusProvidersInjectable),
logger: di.inject(loggerInjectable),
};
return (cluster: Cluster): ClusterContextHandler => {
const clusterUrl = new URLParse(cluster.apiUrl);
return new ContextHandler({
...dependencies,
authProxyCa: di.inject(kubeAuthProxyCertificateInjectable, clusterUrl.hostname).cert,
}, cluster);
};
},
});
export default createContextHandlerInjectable;
+36
View File
@@ -0,0 +1,36 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { DiContainer } from "@ogre-tools/injectable";
import { getInjectable } from "@ogre-tools/injectable";
import { runInAction } from "mobx";
import nodeEnvInjectionToken from "../common/vars/node-env-injection-token";
import { registerInjectables } from "./register-injectables";
import startMainApplicationInjectable from "./start-main-application/start-main-application.injectable";
interface AppConfig {
di: DiContainer;
mode: string;
}
export function createApp(conf: AppConfig) {
const { di, mode } = conf;
runInAction(() => {
di.register(getInjectable({
id: "node-env",
instantiate: () => mode,
injectionToken: nodeEnvInjectionToken,
}));
registerInjectables(di);
});
const startMainApplication = di.inject(startMainApplicationInjectable);
return {
start: () => startMainApplication(),
};
}
@@ -0,0 +1,21 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import { computed } from "mobx";
import { shouldShowResourceInjectionToken } from "../../common/cluster-store/allowed-resources-injection-token";
import type { KubeApiResourceDescriptor } from "../../common/rbac";
import { formatKubeApiResource } from "../../common/rbac";
// TODO: Figure out implementation for this later.
const allowedResourcesInjectable = getInjectable({
id: "allowed-resources",
instantiate: () => computed(() => false),
injectionToken: shouldShowResourceInjectionToken,
lifecycle: lifecycleEnum.keyedSingleton({
getInstanceKey: (di, resource: KubeApiResourceDescriptor) => formatKubeApiResource(resource),
}),
});
export default allowedResourcesInjectable;
@@ -0,0 +1,49 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import type { ClusterDependencies } from "../../common/cluster/cluster";
import { Cluster } from "../../common/cluster/cluster";
import directoryForKubeConfigsInjectable from "../../common/app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable";
import createKubeconfigManagerInjectable from "../kubeconfig-manager/create-kubeconfig-manager.injectable";
import createKubectlInjectable from "../kubectl/create-kubectl.injectable";
import createContextHandlerInjectable from "../context-handler/create-context-handler.injectable";
import { createClusterInjectionToken } from "../../common/cluster/create-cluster-injection-token";
import authorizationReviewInjectable from "../../common/cluster/authorization-review.injectable";
import listNamespacesInjectable from "../../common/cluster/list-namespaces.injectable";
import createListApiResourcesInjectable from "../cluster/request-api-resources.injectable";
import loggerInjectable from "../../common/logger.injectable";
import broadcastMessageInjectable from "../../common/ipc/broadcast-message.injectable";
import loadConfigfromFileInjectable from "../../common/kube-helpers/load-config-from-file.injectable";
import requestNamespaceListPermissionsForInjectable from "../../common/cluster/request-namespace-list-permissions.injectable";
import detectClusterMetadataInjectable from "../cluster-detectors/detect-cluster-metadata.injectable";
import clusterVersionDetectorInjectable from "../cluster-detectors/cluster-version-detector.injectable";
const createClusterInjectable = getInjectable({
id: "create-cluster",
instantiate: (di) => {
const dependencies: ClusterDependencies = {
directoryForKubeConfigs: di.inject(directoryForKubeConfigsInjectable),
logger: di.inject(loggerInjectable),
clusterVersionDetector: di.inject(clusterVersionDetectorInjectable),
createKubeconfigManager: di.inject(createKubeconfigManagerInjectable),
createKubectl: di.inject(createKubectlInjectable),
createContextHandler: di.inject(createContextHandlerInjectable),
createAuthorizationReview: di.inject(authorizationReviewInjectable),
requestNamespaceListPermissionsFor: di.inject(requestNamespaceListPermissionsForInjectable),
requestApiResources: di.inject(createListApiResourcesInjectable),
createListNamespaces: di.inject(listNamespacesInjectable),
broadcastMessage: di.inject(broadcastMessageInjectable),
loadConfigfromFile: di.inject(loadConfigfromFileInjectable),
detectClusterMetadata: di.inject(detectClusterMetadataInjectable),
};
return (model, configData) => new Cluster(dependencies, model, configData);
},
injectionToken: createClusterInjectionToken,
});
export default createClusterInjectable;
@@ -0,0 +1,11 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getGlobalOverride } from "../../common/test-utils/get-global-override";
import randomUUIDInjectable from "./random-uuid.injectable";
export default getGlobalOverride(randomUUIDInjectable, () => () => {
throw new Error("Tried to get a randomUUID without override");
});
@@ -0,0 +1,14 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { randomUUID } from "crypto";
const randomUUIDInjectable = getInjectable({
id: "random-uuid",
instantiate: () => randomUUID,
causesSideEffects: true,
});
export default randomUUIDInjectable;
@@ -0,0 +1,46 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { kebabCase } from "lodash";
import { getGlobalOverride } from "../../common/test-utils/get-global-override";
import electronAppInjectable from "./electron-app.injectable";
export default getGlobalOverride(electronAppInjectable, () => {
const commandLineArgs: string[] = [];
const chromiumArgs = new Map<string, string | undefined>();
const appPaths = new Map<string, string>();
const app = ({
getVersion: () => "6.0.0",
setLoginItemSettings: () => { },
on: () => app,
whenReady: async () => {},
getPath: (name) => appPaths.get(name) ?? `/some-directory-for-${kebabCase(name)}`,
setPath: (name, value) => appPaths.set(name, value),
getAppPath: () => "/some-path-to-the-applcation-binary",
focus: () => {},
commandLine: {
appendArgument: (value) => commandLineArgs.push(value),
appendSwitch: (key, value) => chromiumArgs.set(key, value),
getSwitchValue: (key) => chromiumArgs.get(key),
hasSwitch: (key) => chromiumArgs.has(key),
removeSwitch: (key) => chromiumArgs.delete(key),
},
disableHardwareAcceleration: () => {},
requestSingleInstanceLock: () => true,
getLoginItemSettings: () => ({
executableWillLaunchAtLogin: false,
openAtLogin: false,
openAsHidden: false,
wasOpenedAtLogin: false,
wasOpenedAsHidden: false,
restoreState: false,
launchItems: [],
}),
exit: () => {},
} as Partial<Electron.App> as Electron.App);
return app;
});
@@ -0,0 +1,14 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { app } from "electron";
const electronAppInjectable = getInjectable({
id: "electron-app",
instantiate: () => app,
causesSideEffects: true,
});
export default electronAppInjectable;
@@ -0,0 +1,14 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { autoUpdater } from "electron";
const autoUpdaterInjectable = getInjectable({
id: "auto-updater",
instantiate: () => autoUpdater,
causesSideEffects: true,
});
export default autoUpdaterInjectable;
@@ -0,0 +1,20 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import electronAppInjectable from "../electron-app.injectable";
const disableHardwareAccelerationInjectable = getInjectable({
id: "disable-hardware-acceleration",
instantiate: (di) => {
const app = di.inject(electronAppInjectable);
return () => {
app.disableHardwareAcceleration();
};
},
});
export default disableHardwareAccelerationInjectable;
@@ -0,0 +1,26 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getGlobalOverride } from "../../../common/test-utils/get-global-override";
import electronDialogInjectable from "./electron-dialog.injectable";
export default getGlobalOverride(electronDialogInjectable, () => ({
showCertificateTrustDialog: async () => {},
showErrorBox: () => {},
showMessageBox: async () => ({
checkboxChecked: false,
response: 0,
}),
showMessageBoxSync: () => 0,
showOpenDialog: async () => ({
canceled: true,
filePaths: [],
}),
showOpenDialogSync: () => [],
showSaveDialog: async () => ({
canceled: true,
}),
showSaveDialogSync: () => "",
}));
@@ -0,0 +1,14 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { dialog } from "electron";
const electronDialogInjectable = getInjectable({
id: "electron-dialog",
instantiate: () => dialog,
causesSideEffects: true,
});
export default electronDialogInjectable;
@@ -0,0 +1,20 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import electronUpdaterInjectable from "./electron-updater.injectable";
const electronQuitAndInstallUpdateInjectable = getInjectable({
id: "electron-quit-and-install-update",
instantiate: (di) => {
const electronUpdater = di.inject(electronUpdaterInjectable);
return () => {
electronUpdater.quitAndInstall(true, true);
};
},
});
export default electronQuitAndInstallUpdateInjectable;
@@ -0,0 +1,18 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import electronUpdaterInjectable from "./electron-updater.injectable";
const electronUpdaterIsActiveInjectable = getInjectable({
id: "electron-updater-is-active",
instantiate: (di) => {
const electronUpdater = di.inject(electronUpdaterInjectable);
return electronUpdater.isUpdaterActive();
},
});
export default electronUpdaterIsActiveInjectable;
@@ -0,0 +1,14 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { autoUpdater } from "electron-updater";
const electronUpdaterInjectable = getInjectable({
id: "electron-updater",
instantiate: () => autoUpdater,
causesSideEffects: true,
});
export default electronUpdaterInjectable;
@@ -0,0 +1,18 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import electronAppInjectable from "../electron-app.injectable";
const exitAppInjectable = getInjectable({
id: "exit-app",
instantiate: (di) => () => {
const app = di.inject(electronAppInjectable);
app.exit(0);
},
});
export default exitAppInjectable;
@@ -0,0 +1,20 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import electronAppInjectable from "../electron-app.injectable";
const focusApplicationInjectable = getInjectable({
id: "focus-application",
instantiate: (di) => {
const electronApp = di.inject(electronAppInjectable);
return () => {
electronApp.focus({ steal: true });
};
},
});
export default focusApplicationInjectable;
@@ -0,0 +1,18 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import electronAppInjectable from "../electron-app.injectable";
const getCommandLineSwitchInjectable = getInjectable({
id: "get-command-line-switch",
instantiate: (di) => {
const app = di.inject(electronAppInjectable);
return (name: string) => app.commandLine.getSwitchValue(name);
},
});
export default getCommandLineSwitchInjectable;
@@ -0,0 +1,18 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import nativeThemeInjectable from "./native-theme.injectable";
const getElectronThemeInjectable = getInjectable({
id: "get-electron-theme",
instantiate: (di) => {
const nativeTheme = di.inject(nativeThemeInjectable);
return () => nativeTheme.shouldUseDarkColors ? "dark" : "light";
},
});
export default getElectronThemeInjectable;
@@ -0,0 +1,16 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import EventEmitter from "events";
import { getGlobalOverride } from "../../../common/test-utils/get-global-override";
import nativeThemeInjectable from "./native-theme.injectable";
export default getGlobalOverride(nativeThemeInjectable, () => Object.assign(new EventEmitter(), {
shouldUseDarkColors: true,
inForcedColorsMode: true,
shouldUseHighContrastColors: false,
shouldUseInvertedColorScheme: false,
themeSource: "dark" as const,
}));
@@ -0,0 +1,14 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { nativeTheme } from "electron";
const nativeThemeInjectable = getInjectable({
id: "native-theme",
instantiate: () => nativeTheme,
causesSideEffects: true,
});
export default nativeThemeInjectable;
@@ -0,0 +1,14 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { powerMonitor } from "electron";
const powerMonitorInjectable = getInjectable({
id: "power-monitor",
instantiate: () => powerMonitor,
causesSideEffects: true,
});
export default powerMonitorInjectable;
@@ -0,0 +1,18 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import electronAppInjectable from "../electron-app.injectable";
const requestSingleInstanceLockInjectable = getInjectable({
id: "request-single-instance-lock",
instantiate: (di) => {
const app = di.inject(electronAppInjectable);
return () => app.requestSingleInstanceLock();
},
});
export default requestSingleInstanceLockInjectable;
@@ -0,0 +1,20 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import type { Settings } from "electron";
import electronAppInjectable from "../electron-app.injectable";
export type SetLoginItemSettings = (settings: Settings) => void;
const setLoginItemSettingsInjectable = getInjectable({
id: "set-login-item-settings",
instantiate: (di): SetLoginItemSettings => {
const electronApp = di.inject(electronAppInjectable);
return (settings) => electronApp.setLoginItemSettings(settings);
},
});
export default setLoginItemSettingsInjectable;
@@ -0,0 +1,20 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import electronUpdaterInjectable from "./electron-updater.injectable";
const setUpdateOnQuitInjectable = getInjectable({
id: "set-update-on-quit",
instantiate: (di) => {
const electronUpdater = di.inject(electronUpdaterInjectable);
return (updateOnQuit: boolean) => {
electronUpdater.autoInstallOnAppQuit = updateOnQuit;
};
},
});
export default setUpdateOnQuitInjectable;
@@ -0,0 +1,27 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import electronAppInjectable from "../electron-app.injectable";
import isMacInjectable from "../../../common/vars/is-mac.injectable";
import commandLineArgumentsInjectable from "../../utils/command-line-arguments.injectable";
const shouldStartHiddenInjectable = getInjectable({
id: "should-start-hidden",
instantiate: (di) => {
const app = di.inject(electronAppInjectable);
const isMac = di.inject(isMacInjectable);
const commandLineArguments = di.inject(commandLineArgumentsInjectable);
// 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.)
return (
commandLineArguments.includes("--hidden") ||
(isMac && app.getLoginItemSettings().wasOpenedAsHidden)
);
},
});
export default shouldStartHiddenInjectable;
@@ -0,0 +1,20 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import electronDialogInjectable from "./electron-dialog.injectable";
const showErrorPopupInjectable = getInjectable({
id: "show-error-popup",
instantiate: (di) => {
const dialog = di.inject(electronDialogInjectable);
return (heading: string, message: string) => {
dialog.showErrorBox(heading, message);
};
},
});
export default showErrorPopupInjectable;
@@ -0,0 +1,28 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import electronDialogInjectable from "./electron-dialog.injectable";
export type ShowMessagePopup = (title: string, message: string, detail: string) => void;
const showMessagePopupInjectable = getInjectable({
id: "show-message-popup",
instantiate: (di): ShowMessagePopup => {
const dialog = di.inject(electronDialogInjectable);
return async (title, message, detail) => {
await dialog.showMessageBox({
title,
message,
detail,
type: "info",
buttons: ["Close"],
});
};
},
});
export default showMessagePopupInjectable;
@@ -0,0 +1,9 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getGlobalOverrideForFunction } from "../../../common/test-utils/get-global-override-for-function";
import showOpenDialogInjectable from "./show-open-dialog.injectable";
export default getGlobalOverrideForFunction(showOpenDialogInjectable);
@@ -0,0 +1,17 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import type { OpenDialogOptions } from "electron";
import { dialog } from "electron";
export type ShowOpenDialog = (options: OpenDialogOptions) => Promise<Electron.OpenDialogReturnValue>;
const showOpenDialogInjectable = getInjectable({
id: "show-open-dialog",
instantiate: (): ShowOpenDialog => opts => dialog.showOpenDialog(opts),
causesSideEffects: true,
});
export default showOpenDialogInjectable;
@@ -0,0 +1,33 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { getStartableStoppable } from "../../../common/utils/get-startable-stoppable";
import operatingSystemThemeStateInjectable from "../../theme/operating-system-theme-state.injectable";
import nativeThemeInjectable from "./native-theme.injectable";
import getElectronThemeInjectable from "./get-electron-theme.injectable";
const syncThemeFromOperatingSystemInjectable = getInjectable({
id: "sync-theme-from-operating-system",
instantiate: (di) => {
const currentThemeState = di.inject(operatingSystemThemeStateInjectable);
const nativeTheme = di.inject(nativeThemeInjectable);
const getElectronTheme = di.inject(getElectronThemeInjectable);
return getStartableStoppable("sync-theme-from-operating-system", () => {
const updateThemeState = () => {
currentThemeState.set(getElectronTheme());
};
nativeTheme.on("updated", updateThemeState);
return () => {
nativeTheme.off("updated", updateThemeState);
};
});
},
});
export default syncThemeFromOperatingSystemInjectable;
@@ -0,0 +1,14 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import electronAppInjectable from "../electron-app.injectable";
const waitForElectronToBeReadyInjectable = getInjectable({
id: "wait-for-electron-to-be-ready",
instantiate: (di) => () => di.inject(electronAppInjectable).whenReady(),
});
export default waitForElectronToBeReadyInjectable;
@@ -0,0 +1,24 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { beforeQuitOfBackEndInjectionToken } from "../../start-main-application/runnable-tokens/before-quit-of-back-end-injection-token";
import lensProtocolRouterMainInjectable from "../../protocol-handler/lens-protocol-router-main/lens-protocol-router-main.injectable";
const cleanUpDeepLinkingInjectable = getInjectable({
id: "clean-up-deep-linking",
instantiate: (di) => {
const lensProtocolRouterMain = di.inject(lensProtocolRouterMainInjectable);
return {
id: "clean-up-deep-linking",
run: () => void lensProtocolRouterMain.cleanup(),
};
},
injectionToken: beforeQuitOfBackEndInjectionToken,
});
export default cleanUpDeepLinkingInjectable;
@@ -0,0 +1,35 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { beforeQuitOfFrontEndInjectionToken } from "../../../start-main-application/runnable-tokens/before-quit-of-front-end-injection-token";
import electronAppInjectable from "../../electron-app.injectable";
import { isEmpty } from "lodash/fp";
import getVisibleWindowsInjectable from "../../../start-main-application/lens-window/get-visible-windows.injectable";
const hideDockForLastClosedWindowInjectable = getInjectable({
id: "hide-dock-when-there-are-no-windows",
instantiate: (di) => {
const app = di.inject(electronAppInjectable);
const getVisibleWindows = di.inject(getVisibleWindowsInjectable);
return {
id: "hide-dock-when-there-are-no-windows",
run: () => {
const visibleWindows = getVisibleWindows();
if (isEmpty(visibleWindows)) {
app.dock?.hide();
}
return undefined;
},
};
},
injectionToken: beforeQuitOfFrontEndInjectionToken,
});
export default hideDockForLastClosedWindowInjectable;
Loaded 100 of 3321 files, more files were not shown because too many files have changed in this diff. Show more