From 695f6603875dc45aedb3b6f828cb8b03affb70ef Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Wed, 7 Jul 2021 17:19:40 -0400 Subject: [PATCH] Fix ResourceApplier to not use sync methods within an async context - Add cleanup for apply all - Cleanup files in kubectlCmdAll even on success - Make ResourceApplier injectable - Write some basic tests - Remove only use of logger.silly Signed-off-by: Sebastian Malton --- src/common/__tests__/cluster-store.test.ts | 6 +- src/common/__tests__/hotbar-store.test.ts | 1 - .../authorization-review.injectable.ts | 10 +- src/common/cluster/cluster.ts | 1 - .../cluster/list-namespaces.injectable.ts | 8 +- src/common/fs/remove.injectable.ts | 15 ++ src/common/fs/temp-dir.injectable.ts | 19 ++ src/common/fs/temp-file.injectable.ts | 23 ++ src/common/fs/unlink.injectable.ts | 16 ++ src/common/fs/write-file.injectable.ts | 6 +- src/common/k8s/resource-stack.ts | 9 +- src/common/logger.ts | 1 - src/main/__test__/cluster.test.ts | 11 +- src/main/__test__/kube-auth-proxy.test.ts | 5 +- src/main/__test__/kubeconfig-manager.test.ts | 3 +- .../__test__/kubeconfig-sync.test.ts | 2 + .../child-process/exec-file.injectable.ts | 16 ++ .../create-cluster.injectable.ts | 8 +- .../setup-ipc-main-handlers.injectable.ts | 9 +- .../setup-ipc-main-handlers.ts | 25 +- .../__tests__/applier.test.ts | 250 ++++++++++++++++++ src/main/k8s/resource-applier/applier.ts | 179 +++++++++++++ .../k8s/resource-applier/create.injectable.ts | 36 +++ src/main/resource-applier.ts | 165 ------------ src/main/router/router.injectable.ts | 11 +- src/main/router/router.test.ts | 7 + .../apply-resource-route.injectable.ts | 18 +- .../patch-resource-route.injectable.ts | 30 ++- .../__tests__/delete-cluster-dialog.test.tsx | 6 +- src/test-utils/expects.ts | 36 +++ 30 files changed, 695 insertions(+), 237 deletions(-) create mode 100644 src/common/fs/remove.injectable.ts create mode 100644 src/common/fs/temp-dir.injectable.ts create mode 100644 src/common/fs/temp-file.injectable.ts create mode 100644 src/common/fs/unlink.injectable.ts create mode 100644 src/main/child-process/exec-file.injectable.ts create mode 100644 src/main/k8s/resource-applier/__tests__/applier.test.ts create mode 100644 src/main/k8s/resource-applier/applier.ts create mode 100644 src/main/k8s/resource-applier/create.injectable.ts delete mode 100644 src/main/resource-applier.ts create mode 100644 src/test-utils/expects.ts diff --git a/src/common/__tests__/cluster-store.test.ts b/src/common/__tests__/cluster-store.test.ts index e03e0aa533..bf79cd0d37 100644 --- a/src/common/__tests__/cluster-store.test.ts +++ b/src/common/__tests__/cluster-store.test.ts @@ -94,10 +94,12 @@ describe("cluster-store", () => { mainDi.permitSideEffects(getConfigurationFileModelInjectable); mainDi.permitSideEffects(appVersionInjectable); - mainDi.permitSideEffects(clusterStoreInjectable); - mainDi.permitSideEffects(fsInjectable); mainDi.unoverride(clusterStoreInjectable); + mainDi.permitSideEffects(clusterStoreInjectable); + + mainDi.unoverride(fsInjectable); + mainDi.permitSideEffects(fsInjectable); }); afterEach(() => { diff --git a/src/common/__tests__/hotbar-store.test.ts b/src/common/__tests__/hotbar-store.test.ts index cc8a4dc8fa..1951d2e04c 100644 --- a/src/common/__tests__/hotbar-store.test.ts +++ b/src/common/__tests__/hotbar-store.test.ts @@ -95,7 +95,6 @@ describe("HotbarStore", () => { debug: jest.fn(), error: jest.fn(), info: jest.fn(), - silly: jest.fn(), }; di.override(loggerInjectable, () => loggerMock); diff --git a/src/common/cluster/authorization-review.injectable.ts b/src/common/cluster/authorization-review.injectable.ts index c622893b63..03e292fe08 100644 --- a/src/common/cluster/authorization-review.injectable.ts +++ b/src/common/cluster/authorization-review.injectable.ts @@ -13,7 +13,7 @@ export type CanI = (resourceAttributes: V1ResourceAttributes) => Promise authorizationReview, +const createAuthorizationReviewInjectable = getInjectable({ + id: "create-authorization-review", + instantiate: () => createAuthorizationReview, }); -export default authorizationReviewInjectable; +export default createAuthorizationReviewInjectable; diff --git a/src/common/cluster/cluster.ts b/src/common/cluster/cluster.ts index f3723e140b..4fb59871b7 100644 --- a/src/common/cluster/cluster.ts +++ b/src/common/cluster/cluster.ts @@ -581,7 +581,6 @@ export class Cluster implements ClusterModel, ClusterState { * @param state cluster state */ pushState(state = this.getState()) { - this.dependencies.logger.silly(`[CLUSTER]: push-state`, state); broadcastMessage("cluster:state", this.id, state); } diff --git a/src/common/cluster/list-namespaces.injectable.ts b/src/common/cluster/list-namespaces.injectable.ts index 468ff3ac2e..e2671fc75d 100644 --- a/src/common/cluster/list-namespaces.injectable.ts +++ b/src/common/cluster/list-namespaces.injectable.ts @@ -9,7 +9,7 @@ import { isDefined } from "../utils"; export type ListNamespaces = () => Promise; -export function listNamespaces(config: KubeConfig): ListNamespaces { +function createListNamespaces(config: KubeConfig): ListNamespaces { const coreApi = config.makeApiClient(CoreV1Api); return async () => { @@ -21,9 +21,9 @@ export function listNamespaces(config: KubeConfig): ListNamespaces { }; } -const listNamespacesInjectable = getInjectable({ +const createListNamespacesInjectable = getInjectable({ id: "list-namespaces", - instantiate: () => listNamespaces, + instantiate: () => createListNamespaces, }); -export default listNamespacesInjectable; +export default createListNamespacesInjectable; diff --git a/src/common/fs/remove.injectable.ts b/src/common/fs/remove.injectable.ts new file mode 100644 index 0000000000..4156524247 --- /dev/null +++ b/src/common/fs/remove.injectable.ts @@ -0,0 +1,15 @@ +/** + * 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 fsInjectable from "./fs.injectable"; + +export type RemoveDir = (path: string) => Promise; + +const removeDirInjectable = getInjectable({ + instantiate: (di): RemoveDir => di.inject(fsInjectable).remove, + id: "remove-dir", +}); + +export default removeDirInjectable; diff --git a/src/common/fs/temp-dir.injectable.ts b/src/common/fs/temp-dir.injectable.ts new file mode 100644 index 0000000000..a5b18c735c --- /dev/null +++ b/src/common/fs/temp-dir.injectable.ts @@ -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 tempy from "tempy"; + +export interface TempDirOptions { + prefix?: string; +} + +export type TempDir = (opts?: TempDirOptions) => string; + +const tempDirInjectable = getInjectable({ + id: "temp-dir", + instantiate: (): TempDir => opts => tempy.directory(opts), +}); + +export default tempDirInjectable; diff --git a/src/common/fs/temp-file.injectable.ts b/src/common/fs/temp-file.injectable.ts new file mode 100644 index 0000000000..b089fa0b1e --- /dev/null +++ b/src/common/fs/temp-file.injectable.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import tempy from "tempy"; +import { getInjectable } from "@ogre-tools/injectable"; +import type { MergeExclusive } from "type-fest"; + +export type TempFileOptions = MergeExclusive<{ + name?: string; +}, { + extension?: string; +}>; + +export type TempFile = (opts?: TempFileOptions) => string; + +const tempFileInjectable = getInjectable({ + id: "temp-file", + instantiate: (): TempFile => (opts) => tempy.file(opts), +}); + +export default tempFileInjectable; + diff --git a/src/common/fs/unlink.injectable.ts b/src/common/fs/unlink.injectable.ts new file mode 100644 index 0000000000..20b083c6b7 --- /dev/null +++ b/src/common/fs/unlink.injectable.ts @@ -0,0 +1,16 @@ +/** + * 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 fsInjectable from "./fs.injectable"; + +export type Unlink = (path: string) => Promise; + +const unlinkInjectable = getInjectable({ + instantiate: (di): Unlink => di.inject(fsInjectable).unlink, + id: "unlink", +}); + +export default unlinkInjectable; diff --git a/src/common/fs/write-file.injectable.ts b/src/common/fs/write-file.injectable.ts index 70dcb76373..18fa5d770c 100644 --- a/src/common/fs/write-file.injectable.ts +++ b/src/common/fs/write-file.injectable.ts @@ -6,13 +6,15 @@ import { getInjectable } from "@ogre-tools/injectable"; import path from "path"; import fsInjectable from "./fs.injectable"; +export type WriteFile = (filePath: string, data: string | Buffer) => Promise; + const writeFileInjectable = getInjectable({ id: "write-file", - instantiate: (di) => { + instantiate: (di): WriteFile => { const { writeFile, ensureDir } = di.inject(fsInjectable); - return async (filePath: string, content: string | Buffer) => { + return async (filePath, content) => { await ensureDir(path.dirname(filePath), { mode: 0o755 }); await writeFile(filePath, content, { diff --git a/src/common/k8s/resource-stack.ts b/src/common/k8s/resource-stack.ts index d289a375b3..ad560dda5e 100644 --- a/src/common/k8s/resource-stack.ts +++ b/src/common/k8s/resource-stack.ts @@ -5,7 +5,6 @@ import fse from "fs-extra"; import path from "path"; import hb from "handlebars"; -import { ResourceApplier } from "../../main/resource-applier"; import type { KubernetesCluster } from "../catalog-entities"; import logger from "../../main/logger"; import { app } from "electron"; @@ -13,6 +12,10 @@ import { ClusterStore } from "../cluster-store/cluster-store"; import yaml from "js-yaml"; import { productName } from "../vars"; import { requestKubectlApplyAll, requestKubectlDeleteAll } from "../../renderer/ipc"; +import { asLegacyGlobalFunctionForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/as-legacy-global-function-for-extension-api"; +import createK8sResourceApplierInjectable from "../../main/k8s/resource-applier/create.injectable"; + +const createK8sResourceApplier = asLegacyGlobalFunctionForExtensionApi(createK8sResourceApplierInjectable); export class ResourceStack { constructor(protected cluster: KubernetesCluster, protected name: string) {} @@ -51,7 +54,7 @@ export class ResourceStack { kubectlArgs = this.appendKubectlArgs(kubectlArgs); if (app) { - return await new ResourceApplier(clusterModel).kubectlApplyAll(resources, kubectlArgs); + return await createK8sResourceApplier(clusterModel).kubectlApplyAll(resources, kubectlArgs); } else { const response = await requestKubectlApplyAll(this.cluster.getId(), resources, kubectlArgs); @@ -75,7 +78,7 @@ export class ResourceStack { kubectlArgs = this.appendKubectlArgs(kubectlArgs); if (app) { - return await new ResourceApplier(clusterModel).kubectlDeleteAll(resources, kubectlArgs); + return await createK8sResourceApplier(clusterModel).kubectlDeleteAll(resources, kubectlArgs); } else { const response = await requestKubectlDeleteAll(this.cluster.getId(), resources, kubectlArgs); diff --git a/src/common/logger.ts b/src/common/logger.ts index 7df4db08c7..172f3e2fe8 100644 --- a/src/common/logger.ts +++ b/src/common/logger.ts @@ -15,7 +15,6 @@ export interface Logger { error: (message: string, ...args: any) => void; debug: (message: string, ...args: any) => void; warn: (message: string, ...args: any) => void; - silly: (message: string, ...args: any) => void; } const logLevel = process.env.LOG_LEVEL diff --git a/src/main/__test__/cluster.test.ts b/src/main/__test__/cluster.test.ts index 5e42c8e5fe..07ed281cfc 100644 --- a/src/main/__test__/cluster.test.ts +++ b/src/main/__test__/cluster.test.ts @@ -13,8 +13,6 @@ 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 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"; @@ -23,6 +21,10 @@ import directoryForTempInjectable from "../../common/app-paths/directory-for-tem import normalizedPlatformInjectable from "../../common/vars/normalized-platform.injectable"; import kubectlBinaryNameInjectable from "../kubectl/binary-name.injectable"; import kubectlDownloadingNormalizedArchInjectable from "../kubectl/normalized-arch.injectable"; +import createAuthorizationReviewInjectable from "../../common/cluster/authorization-review.injectable"; +import createListNamespacesInjectable from "../../common/cluster/list-namespaces.injectable"; +import { readFileSync } from "fs-extra"; +import readFileSyncInjectable from "../../common/fs/read-file-sync.injectable"; console = new Console(process.stdout, process.stderr); // fix mockFS @@ -41,8 +43,8 @@ describe("create clusters", () => { di.override(kubectlBinaryNameInjectable, () => "kubectl"); di.override(kubectlDownloadingNormalizedArchInjectable, () => "amd64"); di.override(normalizedPlatformInjectable, () => "darwin"); - di.override(authorizationReviewInjectable, () => () => () => Promise.resolve(true)); - di.override(listNamespacesInjectable, () => () => () => Promise.resolve([ "default" ])); + di.override(createAuthorizationReviewInjectable, () => () => () => Promise.resolve(true)); + di.override(createListNamespacesInjectable, () => () => () => Promise.resolve([ "default" ])); di.override(createContextHandlerInjectable, () => (cluster) => ({ restartServer: jest.fn(), stopServer: jest.fn(), @@ -54,6 +56,7 @@ describe("create clusters", () => { setupPrometheus: jest.fn(), ensureServer: jest.fn(), } as ClusterContextHandler)); + di.override(readFileSyncInjectable, () => readFileSync); // TODO: don't bypass injectables createCluster = di.inject(createClusterInjectionToken); diff --git a/src/main/__test__/kube-auth-proxy.test.ts b/src/main/__test__/kube-auth-proxy.test.ts index ffb1892da2..9fd8c884b8 100644 --- a/src/main/__test__/kube-auth-proxy.test.ts +++ b/src/main/__test__/kube-auth-proxy.test.ts @@ -16,7 +16,6 @@ jest.mock("winston", () => ({ splat: jest.fn(), }, createLogger: jest.fn().mockReturnValue({ - silly: jest.fn(), debug: jest.fn(), log: jest.fn(), info: jest.fn(), @@ -60,6 +59,8 @@ import directoryForTempInjectable from "../../common/app-paths/directory-for-tem import normalizedPlatformInjectable from "../../common/vars/normalized-platform.injectable"; import kubectlBinaryNameInjectable from "../kubectl/binary-name.injectable"; import kubectlDownloadingNormalizedArchInjectable from "../kubectl/normalized-arch.injectable"; +import readFileSyncInjectable from "../../common/fs/read-file-sync.injectable"; +import { readFileSync } from "fs"; console = new Console(stdout, stderr); @@ -116,8 +117,8 @@ describe("kube auth proxy tests", () => { mockFs(mockMinikubeConfig); + di.override(readFileSyncInjectable, () => readFileSync); // TODO: don't bypass injectables createCluster = di.inject(createClusterInjectionToken); - createKubeAuthProxy = di.inject(createKubeAuthProxyInjectable); }); diff --git a/src/main/__test__/kubeconfig-manager.test.ts b/src/main/__test__/kubeconfig-manager.test.ts index 9cac0063ee..76d8fe29a7 100644 --- a/src/main/__test__/kubeconfig-manager.test.ts +++ b/src/main/__test__/kubeconfig-manager.test.ts @@ -23,6 +23,7 @@ import directoryForUserDataInjectable from "../../common/app-paths/directory-for import normalizedPlatformInjectable from "../../common/vars/normalized-platform.injectable"; import kubectlBinaryNameInjectable from "../kubectl/binary-name.injectable"; import kubectlDownloadingNormalizedArchInjectable from "../kubectl/normalized-arch.injectable"; +import readFileSyncInjectable from "../../common/fs/read-file-sync.injectable"; console = new Console(process.stdout, process.stderr); // fix mockFS @@ -48,7 +49,6 @@ describe("kubeconfig manager tests", () => { debug: jest.fn(), error: jest.fn(), info: jest.fn(), - silly: jest.fn(), }; di.override(loggerInjectable, () => loggerMock); @@ -89,6 +89,7 @@ describe("kubeconfig manager tests", () => { ensureServer: jest.fn(), })); + di.override(readFileSyncInjectable, () => fse.readFileSync); // TODO: don't bypass injectables const createCluster = di.inject(createClusterInjectionToken); createKubeconfigManager = di.inject(createKubeconfigManagerInjectable); diff --git a/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts b/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts index 72acbb3c03..16e8b86a6c 100644 --- a/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts +++ b/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts @@ -55,7 +55,9 @@ describe("kubeconfig-sync.source tests", () => { di.override(kubectlDownloadingNormalizedArchInjectable, () => "amd64"); di.override(normalizedPlatformInjectable, () => "darwin"); + di.unoverride(fsInjectable); di.permitSideEffects(fsInjectable); + di.unoverride(clusterStoreInjectable); di.permitSideEffects(clusterStoreInjectable); di.permitSideEffects(getConfigurationFileModelInjectable); diff --git a/src/main/child-process/exec-file.injectable.ts b/src/main/child-process/exec-file.injectable.ts new file mode 100644 index 0000000000..6db35b0e27 --- /dev/null +++ b/src/main/child-process/exec-file.injectable.ts @@ -0,0 +1,16 @@ +/** + * 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 { execFile } from "child_process"; +import { promisify } from "util"; + +export type ExecFile = typeof execFile["__promisify__"]; + +const execFileInjectable = getInjectable({ + id: "exec-file", + instantiate: (): ExecFile => promisify(execFile), +}); + +export default execFileInjectable; diff --git a/src/main/create-cluster/create-cluster.injectable.ts b/src/main/create-cluster/create-cluster.injectable.ts index 07c2ee3247..15ecd0fb97 100644 --- a/src/main/create-cluster/create-cluster.injectable.ts +++ b/src/main/create-cluster/create-cluster.injectable.ts @@ -10,8 +10,8 @@ import createKubeconfigManagerInjectable from "../kubeconfig-manager/create-kube 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 createAuthorizationReviewInjectable from "../../common/cluster/authorization-review.injectable"; +import createListNamespacesInjectable from "../../common/cluster/list-namespaces.injectable"; import loggerInjectable from "../../common/logger.injectable"; import detectorRegistryInjectable from "../cluster-detectors/detector-registry.injectable"; import createVersionDetectorInjectable from "../cluster-detectors/create-version-detector.injectable"; @@ -25,8 +25,8 @@ const createClusterInjectable = getInjectable({ createKubeconfigManager: di.inject(createKubeconfigManagerInjectable), createKubectl: di.inject(createKubectlInjectable), createContextHandler: di.inject(createContextHandlerInjectable), - createAuthorizationReview: di.inject(authorizationReviewInjectable), - createListNamespaces: di.inject(listNamespacesInjectable), + createAuthorizationReview: di.inject(createAuthorizationReviewInjectable), + createListNamespaces: di.inject(createListNamespacesInjectable), logger: di.inject(loggerInjectable), detectorRegistry: di.inject(detectorRegistryInjectable), createVersionDetector: di.inject(createVersionDetectorInjectable), diff --git a/src/main/electron-app/runnables/setup-ipc-main-handlers/setup-ipc-main-handlers.injectable.ts b/src/main/electron-app/runnables/setup-ipc-main-handlers/setup-ipc-main-handlers.injectable.ts index 24f90f01b3..fb4677240a 100644 --- a/src/main/electron-app/runnables/setup-ipc-main-handlers/setup-ipc-main-handlers.injectable.ts +++ b/src/main/electron-app/runnables/setup-ipc-main-handlers/setup-ipc-main-handlers.injectable.ts @@ -14,17 +14,14 @@ import { onLoadOfApplicationInjectionToken } from "../../../start-main-applicati import operatingSystemThemeInjectable from "../../../theme/operating-system-theme.injectable"; import catalogEntityRegistryInjectable from "../../../catalog/entity-registry.injectable"; import askUserForFilePathsInjectable from "../../../ipc/ask-user-for-file-paths.injectable"; +import createK8sResourceApplierInjectable from "../../../k8s/resource-applier/create.injectable"; const setupIpcMainHandlersInjectable = getInjectable({ id: "setup-ipc-main-handlers", instantiate: (di) => { const logger = di.inject(loggerInjectable); - - const directoryForLensLocalStorage = di.inject( - directoryForLensLocalStorageInjectable, - ); - + const directoryForLensLocalStorage = di.inject(directoryForLensLocalStorageInjectable); const clusterManager = di.inject(clusterManagerInjectable); const applicationMenuItems = di.inject(applicationMenuItemsInjectable); const getAbsolutePath = di.inject(getAbsolutePathInjectable); @@ -32,6 +29,7 @@ const setupIpcMainHandlersInjectable = getInjectable({ const clusterStore = di.inject(clusterStoreInjectable); const operatingSystemTheme = di.inject(operatingSystemThemeInjectable); const askUserForFilePaths = di.inject(askUserForFilePathsInjectable); + const createK8sResourceApplier = di.inject(createK8sResourceApplierInjectable); return { run: () => { @@ -46,6 +44,7 @@ const setupIpcMainHandlersInjectable = getInjectable({ clusterStore, operatingSystemTheme, askUserForFilePaths, + createK8sResourceApplier, }); }, }; diff --git a/src/main/electron-app/runnables/setup-ipc-main-handlers/setup-ipc-main-handlers.ts b/src/main/electron-app/runnables/setup-ipc-main-handlers/setup-ipc-main-handlers.ts index 9342696c77..7924cf24be 100644 --- a/src/main/electron-app/runnables/setup-ipc-main-handlers/setup-ipc-main-handlers.ts +++ b/src/main/electron-app/runnables/setup-ipc-main-handlers/setup-ipc-main-handlers.ts @@ -13,7 +13,6 @@ import { broadcastMainChannel, broadcastMessage, ipcMainHandle, ipcMainOn } from import type { CatalogEntityRegistry } from "../../../catalog"; import { pushCatalogToRenderer } from "../../../catalog-pusher"; import type { ClusterManager } from "../../../cluster-manager"; -import { ResourceApplier } from "../../../resource-applier"; import { remove } from "fs-extra"; import type { IComputedValue } from "mobx"; import type { GetAbsolutePath } from "../../../../common/path/get-absolute-path.injectable"; @@ -24,6 +23,7 @@ import { openFilePickingDialogChannel } from "../../../../common/ipc/dialog"; import { getNativeThemeChannel } from "../../../../common/ipc/native-theme"; import type { Theme } from "../../../theme/operating-system-theme-state.injectable"; import type { AskUserForFilePaths } from "../../../ipc/ask-user-for-file-paths.injectable"; +import type { CreateK8sResourceApplier } from "../../../k8s/resource-applier/create.injectable"; interface Dependencies { directoryForLensLocalStorage: string; @@ -34,9 +34,20 @@ interface Dependencies { clusterStore: ClusterStore; operatingSystemTheme: IComputedValue; askUserForFilePaths: AskUserForFilePaths; + createK8sResourceApplier: CreateK8sResourceApplier; } -export const setupIpcMainHandlers = ({ applicationMenuItems, directoryForLensLocalStorage, getAbsolutePath, clusterManager, catalogEntityRegistry, clusterStore, operatingSystemTheme, askUserForFilePaths }: Dependencies) => { +export function setupIpcMainHandlers({ + applicationMenuItems, + directoryForLensLocalStorage, + getAbsolutePath, + clusterManager, + catalogEntityRegistry, + clusterStore, + operatingSystemTheme, + askUserForFilePaths, + createK8sResourceApplier, +}: Dependencies) { ipcMainHandle(clusterActivateHandler, (event, clusterId: ClusterId, force = false) => { return ClusterStore.getInstance() .getById(clusterId) @@ -113,10 +124,8 @@ export const setupIpcMainHandlers = ({ applicationMenuItems, directoryForLensLoc const cluster = ClusterStore.getInstance().getById(clusterId); if (cluster) { - const applier = new ResourceApplier(cluster); - try { - const stdout = await applier.kubectlApplyAll(resources, extraArgs); + const stdout = await createK8sResourceApplier(cluster).kubectlApplyAll(resources, extraArgs); return { stdout }; } catch (error: any) { @@ -132,10 +141,8 @@ export const setupIpcMainHandlers = ({ applicationMenuItems, directoryForLensLoc const cluster = ClusterStore.getInstance().getById(clusterId); if (cluster) { - const applier = new ResourceApplier(cluster); - try { - const stdout = await applier.kubectlDeleteAll(resources, extraArgs); + const stdout = await createK8sResourceApplier(cluster).kubectlDeleteAll(resources, extraArgs); return { stdout }; } catch (error: any) { @@ -172,4 +179,4 @@ export const setupIpcMainHandlers = ({ applicationMenuItems, directoryForLensLoc }); clusterStore.provideInitialFromMain(); -}; +} diff --git a/src/main/k8s/resource-applier/__tests__/applier.test.ts b/src/main/k8s/resource-applier/__tests__/applier.test.ts new file mode 100644 index 0000000000..da31dc67ef --- /dev/null +++ b/src/main/k8s/resource-applier/__tests__/applier.test.ts @@ -0,0 +1,250 @@ +/** + * 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 type { ChildProcess } from "child_process"; +import path from "path"; +import directoryForKubeConfigsInjectable from "../../../../common/app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable"; +import createAuthorizationReviewInjectable from "../../../../common/cluster/authorization-review.injectable"; +import createListNamespacesInjectable from "../../../../common/cluster/list-namespaces.injectable"; +import readFileSyncInjectable from "../../../../common/fs/read-file-sync.injectable"; +import type { RemoveDir } from "../../../../common/fs/remove.injectable"; +import removeDirInjectable from "../../../../common/fs/remove.injectable"; +import tempDirInjectable from "../../../../common/fs/temp-dir.injectable"; +import tempFileInjectable from "../../../../common/fs/temp-file.injectable"; +import type { Unlink } from "../../../../common/fs/unlink.injectable"; +import unlinkInjectable from "../../../../common/fs/unlink.injectable"; +import type { WriteFile } from "../../../../common/fs/write-file.injectable"; +import writeFileInjectable from "../../../../common/fs/write-file.injectable"; +import { expectInSetOnce } from "../../../../test-utils/expects"; +import type { ExecFile } from "../../../child-process/exec-file.injectable"; +import execFileInjectable from "../../../child-process/exec-file.injectable"; +import createContextHandlerInjectable from "../../../context-handler/create-context-handler.injectable"; +import createClusterInjectable from "../../../create-cluster/create-cluster.injectable"; +import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; +import createKubeconfigManagerInjectable from "../../../kubeconfig-manager/create-kubeconfig-manager.injectable"; +import type { KubeconfigManager } from "../../../kubeconfig-manager/kubeconfig-manager"; +import createKubectlInjectable from "../../../kubectl/create-kubectl.injectable"; +import type { Kubectl } from "../../../kubectl/kubectl"; +import type { K8sResourceApplier } from "../applier"; +import createK8sResourceApplierInjectable from "../create.injectable"; + +describe("ResourceApplier", () => { + let di: DiContainer; + let writeFile: jest.MockedFunction; + let execFile: jest.MockedFunction; + let unlink: jest.MockedFunction; + let removeDir: jest.MockedFunction; + let resourceApplier: K8sResourceApplier; + + beforeEach(() => { + di = getDiForUnitTesting({ doGeneralOverrides: true }); + + di.override(createKubectlInjectable, () => jest.fn().mockImplementation((): Partial => ({ + ensureKubectl: jest.fn(), + getPath: () => Promise.resolve("some-path"), + }))); + di.override(directoryForKubeConfigsInjectable, () => "some/path"); + di.override(createKubeconfigManagerInjectable, () => jest.fn().mockImplementation((): Partial => ({ + getPath: () => Promise.resolve("some-proxy-path"), + }))); + di.override(createContextHandlerInjectable, () => jest.fn()); + di.override(createAuthorizationReviewInjectable, () => jest.fn()); + di.override(createListNamespacesInjectable, () => jest.fn()); + di.override(writeFileInjectable, () => writeFile = jest.fn()); + di.override(execFileInjectable, () => execFile = jest.fn()); + di.override(unlinkInjectable, () => unlink = jest.fn()); + di.override(removeDirInjectable, () => removeDir = jest.fn()); + di.override(tempDirInjectable, () => jest.fn().mockImplementation(() => "some/temp/dir")); + di.override(tempFileInjectable, () => jest.fn().mockImplementation(() => "some/temp/file")); + di.override(readFileSyncInjectable, () => jest.fn().mockImplementation(() => { + return JSON.stringify({ + clusters: [{ + name: "some-cluster", + cluster: { + server: "some-server-url", + }, + }], + users: [{ + name: "some-user", + }], + contexts: [{ + name: "some-context", + context: { + user: "some-user", + cluster: "some-cluster", + }, + }], + }); + })); + + const createK8sResourceApplier = di.inject(createK8sResourceApplierInjectable); + const createCluster = di.inject(createClusterInjectable); + + resourceApplier = createK8sResourceApplier(createCluster({ + contextName: "some-context", + id: "some-id", + kubeConfigPath: "some/path/config", + })); + }); + + describe(".apply()", () => { + it("should call unlink, if writeFile rejects", async () => { + writeFile.mockImplementation(() => { + throw new Error("irrelavent"); + }); + + await expect(resourceApplier.apply({})).rejects.toBeTruthy(); + expect(unlink).toBeCalledWith("some/temp/file"); + }); + + it("should call unlink, if execFile rejects", async () => { + execFile.mockImplementation(() => { + throw new Error("irrelavent"); + }); + + await expect(resourceApplier.apply({})).rejects.toBeTruthy(); + expect(unlink).toBeCalledWith("some/temp/file"); + }); + + it("should call unlink, if everything passes", async () => { + execFile.mockImplementation(() => Object.assign( + Promise.resolve({ + stdout: "I am some output", + stderr: "", + }), + { + child: {} as ChildProcess, + }, + )); + + await expect(resourceApplier.apply({})).resolves.toBeTruthy(); + expect(unlink).toBeCalledWith("some/temp/file"); + }); + + it("should return the stdout of execFile", async () => { + execFile.mockImplementation(() => Object.assign( + Promise.resolve({ + stdout: "I am some output", + stderr: "", + }), + { + child: {} as ChildProcess, + }, + )); + + expect(await resourceApplier.apply({})).toBe("I am some output"); + }); + + it("should build up a correct set of arguments", async () => { + execFile.mockImplementation((path, args) => { + expect(args).toEqual([ + "apply", + "--kubeconfig", + "some-proxy-path", + "-o", + "json", + "-f", + "some/temp/file", + ]); + + return Object.assign( + Promise.resolve({ + stdout: "I am some output", + stderr: "", + }), + { + child: {} as ChildProcess, + }, + ); + }); + + await resourceApplier.apply({}); + }); + }); + + describe(".kubectlApplyAll()", () => { + it("should call removeDir, if any writeFile rejects", async () => { + let count = 0; + + writeFile.mockImplementation(async () => { + count += 1; + + if (count === 2) { + throw new Error("irrelavent"); + } + }); + + await expect(resourceApplier.kubectlApplyAll(["foo", "bar"])).rejects.toBeTruthy(); + expect(removeDir).toBeCalledWith("some/temp/dir"); + }); + + it("should call removeDir, if any execFile rejects", async () => { + execFile.mockImplementation(() => { + throw new Error("irrelavent"); + }); + + await expect(resourceApplier.kubectlApplyAll(["foo", "bar"])).rejects.toBeTruthy(); + expect(removeDir).toBeCalledWith("some/temp/dir"); + }); + + it("should call writeFile for each resource", async () => { + const resources = new Set(["foo", "bar"]); + const onlyOnce = expectInSetOnce(resources); + + writeFile.mockImplementation(async (filePath, contents) => { + if (path.sep === "/") { + expect(filePath).toMatch(/^some\/temp\/dir\/[0-9]+.yaml$/); + } else { + expect(filePath).toMatch(/^some\\temp\\dir\\[0-9]+.yaml$/); + } + onlyOnce(contents); + }); + + execFile.mockImplementation(() => { + return Object.assign( + Promise.resolve({ + stdout: "I am some output", + stderr: "", + }), + { + child: {} as ChildProcess, + }, + ); + }); + + await expect(resourceApplier.kubectlApplyAll([...resources])).resolves.toBeTruthy(); + expect(removeDir).toBeCalledWith("some/temp/dir"); + onlyOnce.allSatisfied(); + }); + + it("should use resonable arguments", async () => { + execFile.mockImplementation((path, args) => { + expect(args).toEqual([ + "apply", + "--kubeconfig", + "some-proxy-path", + "-o", + "json", + "-f", + "some/temp/dir", + ]); + + return Object.assign( + Promise.resolve({ + stdout: "I am some output", + stderr: "", + }), + { + child: {} as ChildProcess, + }, + ); + }); + + await expect(resourceApplier.kubectlApplyAll(["foo", "bar"])).resolves.toBeTruthy(); + expect(removeDir).toBeCalledWith("some/temp/dir"); + }); + }); +}); diff --git a/src/main/k8s/resource-applier/applier.ts b/src/main/k8s/resource-applier/applier.ts new file mode 100644 index 0000000000..32c39e225d --- /dev/null +++ b/src/main/k8s/resource-applier/applier.ts @@ -0,0 +1,179 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { Cluster } from "../../../common/cluster/cluster"; +import type { KubernetesObject } from "@kubernetes/client-node"; +import * as yaml from "js-yaml"; +import path from "path"; +import { appEventBus } from "../../../common/app-event-bus/event-bus"; +import type { Patch } from "rfc6902"; +import type { ExecFile } from "../../child-process/exec-file.injectable"; +import type { WriteFile } from "../../../common/fs/write-file.injectable"; +import type { Unlink } from "../../../common/fs/unlink.injectable"; +import type { RemoveDir } from "../../../common/fs/remove.injectable"; +import type { PartialDeep } from "type-fest"; +import type { KubeObject } from "../../../common/k8s-api/kube-object"; +import type { Logger } from "../../../common/logger"; +import type { TempFile } from "../../../common/fs/temp-file.injectable"; +import type { TempDir } from "../../../common/fs/temp-dir.injectable"; +import { hasTypedProperty, isObject, isString } from "../../../common/utils"; + +export interface ResourceApplierDependencies { + execFile: ExecFile; + writeFile: WriteFile; + unlink: Unlink; + removeDir: RemoveDir; + tempFile: TempFile; + tempDir: TempDir; + readonly logger: Logger; +} + +export interface K8sResourceApplier { + patch(name: string, kind: string, patch: Patch, ns?: string): Promise; + apply(resource: PartialDeep): Promise; + /** + * @deprecated This function is only really for KubeObject's + */ + apply(resource: any): Promise; + kubectlApplyAll(resources: string[], extraArgs?: string[]): Promise; + kubectlDeleteAll(resources: string[], extraArgs?: string[]): Promise; +} + +export class ResourceApplier implements K8sResourceApplier { + constructor(protected readonly dependencies: ResourceApplierDependencies, protected readonly cluster: Cluster) {} + + /** + * Patch a kube resource's manifest, throwing any error that occurs. + * @param name The name of the kube resource + * @param kind The kind of the kube resource + * @param patch The list of JSON operations + * @param ns The optional namespace of the kube resource + */ + async patch(name: string, kind: string, patch: Patch, ns?: string): Promise { + appEventBus.emit({ name: "resource", action: "patch" }); + + const kubectl = await this.cluster.ensureKubectl(); + const kubectlPath = await kubectl.getPath(); + const proxyKubeconfigPath = await this.cluster.getProxyKubeconfigPath(); + const args = [ + "--kubeconfig", proxyKubeconfigPath, + "patch", + kind, + name, + ]; + + if (ns) { + args.push("--namespace", ns); + } + + args.push( + "--type", "json", + "--patch", JSON.stringify(patch), + "-o", "json", + ); + + try { + const { stdout } = await this.dependencies.execFile(kubectlPath, args); + + return stdout; + } catch (error) { + if (isObject(error) && hasTypedProperty(error, "stderr", isString)) { + throw error.stderr; + } + + throw String(error); + } + } + + async apply(resource: KubernetesObject | any): Promise { + appEventBus.emit({ name: "resource", action: "apply" }); + + const content = yaml.dump(sanitizeObject(resource)); + const kubectl = await this.cluster.ensureKubectl(); + const kubectlPath = await kubectl.getPath(); + const proxyKubeconfigPath = await this.cluster.getProxyKubeconfigPath(); + const fileName = this.dependencies.tempFile({ name: "resource.yaml" }); + + const args = [ + "apply", + "--kubeconfig", proxyKubeconfigPath, + "-o", "json", + "-f", fileName, + ]; + + this.dependencies.logger.debug(`[RESOURCE-APPLIER]: shooting manifests with: ${kubectlPath}`, { args }); + + const execEnv = { ...process.env }; + const httpsProxy = this.cluster.preferences?.httpsProxy; + + if (httpsProxy) { + execEnv.HTTPS_PROXY = httpsProxy; + } + + try { + await this.dependencies.writeFile(fileName, content); + const { stdout } = await this.dependencies.execFile(kubectlPath, args, { env: execEnv }); + + return stdout; + } catch (error) { + if (isObject(error) && hasTypedProperty(error, "stderr", isString)) { + throw error.stderr; + } + + throw String(error); + } finally { + await this.dependencies.unlink(fileName); + } + } + + public async kubectlApplyAll(resources: string[], extraArgs = ["-o", "json"]): Promise { + return this.kubectlCmdAll("apply", resources, extraArgs); + } + + public async kubectlDeleteAll(resources: string[], extraArgs: string[] = []): Promise { + return this.kubectlCmdAll("delete", resources, extraArgs); + } + + protected async kubectlCmdAll(subCmd: string, resources: string[], args: string[]): Promise { + const kubectl = await this.cluster.ensureKubectl(); + const kubectlPath = await kubectl.getPath(); + const proxyKubeconfigPath = await this.cluster.getProxyKubeconfigPath(); + const tmpDir = this.dependencies.tempDir(); + + try { + await Promise.all( + resources.map((resource, index) => this.dependencies.writeFile(path.join(tmpDir, `${index}.yaml`), resource)), + ); + + args.unshift( + subCmd, + "--kubeconfig", proxyKubeconfigPath, + ); + args.push("-f", tmpDir); + + this.dependencies.logger.info(`[RESOURCE-APPLIER] Executing ${kubectlPath}`, { args }); + + const { stdout } = await this.dependencies.execFile(kubectlPath, args); + + return stdout; + } catch (error) { + this.dependencies.logger.error(`[RESOURCE-APPLIER] cmd errored: ${error}`); + + throw String(error).split(`.yaml": `)[1] ?? error; + } finally { + await this.dependencies.removeDir(tmpDir); + } + } +} + +function sanitizeObject(resource: KubernetesObject | any) { + const cleaned = JSON.parse(JSON.stringify(resource)); + + delete cleaned.status; + delete cleaned.metadata?.resourceVersion; + delete cleaned.metadata?.annotations?.["kubectl.kubernetes.io/last-applied-configuration"]; + + return cleaned; +} diff --git a/src/main/k8s/resource-applier/create.injectable.ts b/src/main/k8s/resource-applier/create.injectable.ts new file mode 100644 index 0000000000..145829322c --- /dev/null +++ b/src/main/k8s/resource-applier/create.injectable.ts @@ -0,0 +1,36 @@ +/** + * 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 removeDirInjectable from "../../../common/fs/remove.injectable"; +import tempDirInjectable from "../../../common/fs/temp-dir.injectable"; +import tempFileInjectable from "../../../common/fs/temp-file.injectable"; +import unlinkInjectable from "../../../common/fs/unlink.injectable"; +import writeFileInjectable from "../../../common/fs/write-file.injectable"; +import loggerInjectable from "../../../common/logger.injectable"; +import execFileInjectable from "../../child-process/exec-file.injectable"; +import type { K8sResourceApplier, ResourceApplierDependencies } from "./applier"; +import { ResourceApplier } from "./applier"; + +export type CreateK8sResourceApplier = (cluster: Cluster) => K8sResourceApplier; + +const createK8sResourceApplierInjectable = getInjectable({ + id: "create-k8s-resource-applier", + instantiate: (di): CreateK8sResourceApplier => { + const deps: ResourceApplierDependencies = { + execFile: di.inject(execFileInjectable), + removeDir: di.inject(removeDirInjectable), + unlink: di.inject(unlinkInjectable), + writeFile: di.inject(writeFileInjectable), + logger: di.inject(loggerInjectable), + tempDir: di.inject(tempDirInjectable), + tempFile: di.inject(tempFileInjectable), + }; + + return (cluster) => new ResourceApplier(deps, cluster); + }, +}); + +export default createK8sResourceApplierInjectable; diff --git a/src/main/resource-applier.ts b/src/main/resource-applier.ts deleted file mode 100644 index 30c85e8fa0..0000000000 --- a/src/main/resource-applier.ts +++ /dev/null @@ -1,165 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ - -import type { Cluster } from "../common/cluster/cluster"; -import type { KubernetesObject } from "@kubernetes/client-node"; -import { exec } from "child_process"; -import fs from "fs-extra"; -import * as yaml from "js-yaml"; -import path from "path"; -import tempy from "tempy"; -import logger from "./logger"; -import { appEventBus } from "../common/app-event-bus/event-bus"; -import { isChildProcessError } from "../common/utils"; -import type { Patch } from "rfc6902"; -import { promiseExecFile } from "../common/utils/promise-exec"; - -export class ResourceApplier { - constructor(protected cluster: Cluster) {} - - /** - * Patch a kube resource's manifest, throwing any error that occurs. - * @param name The name of the kube resource - * @param kind The kind of the kube resource - * @param patch The list of JSON operations - * @param ns The optional namespace of the kube resource - */ - async patch(name: string, kind: string, patch: Patch, ns?: string): Promise { - appEventBus.emit({ name: "resource", action: "patch" }); - - const kubectl = await this.cluster.ensureKubectl(); - const kubectlPath = await kubectl.getPath(); - const proxyKubeconfigPath = await this.cluster.getProxyKubeconfigPath(); - const args = [ - "--kubeconfig", proxyKubeconfigPath, - "patch", - kind, - name, - ]; - - if (ns) { - args.push("--namespace", ns); - } - - args.push( - "--type", "json", - "--patch", JSON.stringify(patch), - "-o", "json", - ); - - try { - const { stdout } = await promiseExecFile(kubectlPath, args); - - return stdout; - } catch (error) { - if (isChildProcessError(error)) { - throw error.stderr ?? error; - } - - throw error; - } - } - - async apply(resource: KubernetesObject | any): Promise { - resource = this.sanitizeObject(resource); - appEventBus.emit({ name: "resource", action: "apply" }); - - return this.kubectlApply(yaml.dump(resource)); - } - - protected async kubectlApply(content: string): Promise { - const kubectl = await this.cluster.ensureKubectl(); - const kubectlPath = await kubectl.getPath(); - const proxyKubeconfigPath = await this.cluster.getProxyKubeconfigPath(); - const fileName = tempy.file({ name: "resource.yaml" }); - const args = [ - "apply", - "--kubeconfig", proxyKubeconfigPath, - "-o", "json", - "-f", fileName, - ]; - - logger.debug(`shooting manifests with ${kubectlPath}`, { args }); - - const execEnv = { ...process.env }; - const httpsProxy = this.cluster.preferences?.httpsProxy; - - if (httpsProxy) { - execEnv.HTTPS_PROXY = httpsProxy; - } - - try { - await fs.writeFile(fileName, content); - const { stdout } = await promiseExecFile(kubectlPath, args); - - return stdout; - } catch (error) { - if (isChildProcessError(error)) { - throw error.stderr ?? error; - } - - throw error; - } finally { - await fs.unlink(fileName); - } - } - - public async kubectlApplyAll(resources: string[], extraArgs = ["-o", "json"]): Promise { - return this.kubectlCmdAll("apply", resources, extraArgs); - } - - public async kubectlDeleteAll(resources: string[], extraArgs?: string[]): Promise { - return this.kubectlCmdAll("delete", resources, extraArgs); - } - - protected async kubectlCmdAll(subCmd: string, resources: string[], args: string[] = []): Promise { - const kubectl = await this.cluster.ensureKubectl(); - const kubectlPath = await kubectl.getPath(); - const proxyKubeconfigPath = await this.cluster.getProxyKubeconfigPath(); - - return new Promise((resolve, reject) => { - const tmpDir = tempy.directory(); - - // Dump each resource into tmpDir - resources.forEach((resource, index) => { - fs.writeFileSync(path.join(tmpDir, `${index}.yaml`), resource); - }); - args.push("-f", `"${tmpDir}"`); - const cmd = `"${kubectlPath}" ${subCmd} --kubeconfig "${proxyKubeconfigPath}" ${args.join(" ")}`; - - logger.info(`[RESOURCE-APPLIER] running cmd ${cmd}`); - exec(cmd, (error, stdout) => { - if (error) { - logger.error(`[RESOURCE-APPLIER] cmd errored: ${error}`); - const splitError = error.toString().split(`.yaml": `); - - if (splitError[1]) { - reject(splitError[1]); - } else { - reject(error); - } - - return; - } - - resolve(stdout); - }); - }); - } - - protected sanitizeObject(resource: KubernetesObject | any) { - const res = JSON.parse(JSON.stringify(resource)); - - delete res.status; - delete res.metadata?.resourceVersion; - const annotations = res.metadata?.annotations; - - if (annotations) { - delete annotations["kubectl.kubernetes.io/last-applied-configuration"]; - } - - return res; - } -} diff --git a/src/main/router/router.injectable.ts b/src/main/router/router.injectable.ts index 23d6c78fc4..578d5971f0 100644 --- a/src/main/router/router.injectable.ts +++ b/src/main/router/router.injectable.ts @@ -2,8 +2,8 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { Injectable } from "@ogre-tools/injectable"; -import { getInjectable, getInjectionToken, lifecycleEnum } from "@ogre-tools/injectable"; +import type { Injectable, InjectionToken } from "@ogre-tools/injectable"; +import { getInjectable, getInjectionToken } from "@ogre-tools/injectable"; import { Router } from "./router"; import parseRequestInjectable from "./parse-request.injectable"; import type { Route } from "./route"; @@ -18,11 +18,10 @@ export function getRouteInjectable< >( opts: Omit, Route, void>, "lifecycle" | "injectionToken">, ): Injectable, Route, void> { - return { + return getInjectable({ ...opts, - injectionToken: routeInjectionToken as never, - lifecycle: lifecycleEnum.singleton as never, - }; + injectionToken: routeInjectionToken as unknown as InjectionToken, void>, + }); } const routerInjectable = getInjectable({ diff --git a/src/main/router/router.test.ts b/src/main/router/router.test.ts index a8467df3f2..42ed8e7527 100644 --- a/src/main/router/router.test.ts +++ b/src/main/router/router.test.ts @@ -13,6 +13,7 @@ import type { AsyncFnMock } from "@async-fn/jest"; import asyncFn from "@async-fn/jest"; import parseRequestInjectable from "./parse-request.injectable"; import { contentTypes } from "./router-content-types"; +import createK8sResourceApplierInjectable from "../k8s/resource-applier/create.injectable"; import mockFs from "mock-fs"; import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; import type { Route } from "./route"; @@ -40,6 +41,12 @@ describe("router", () => { di.override(kubectlBinaryNameInjectable, () => "kubectl"); di.override(kubectlDownloadingNormalizedArchInjectable, () => "amd64"); di.override(normalizedPlatformInjectable, () => "darwin"); + di.override(createK8sResourceApplierInjectable, () => () => ({ + apply: jest.fn(), + kubectlApplyAll: jest.fn(), + kubectlDeleteAll: jest.fn(), + patch: jest.fn(), + })); const injectable = getInjectable({ id: "some-route", diff --git a/src/main/routes/resource-applier/apply-resource-route.injectable.ts b/src/main/routes/resource-applier/apply-resource-route.injectable.ts index 45f484fd3f..92464a15aa 100644 --- a/src/main/routes/resource-applier/apply-resource-route.injectable.ts +++ b/src/main/routes/resource-applier/apply-resource-route.injectable.ts @@ -4,18 +4,22 @@ */ import { getRouteInjectable } from "../../router/router.injectable"; import { apiPrefix } from "../../../common/vars"; -import { ResourceApplier } from "../../resource-applier"; import { clusterRoute } from "../../router/route"; +import createK8sResourceApplierInjectable from "../../k8s/resource-applier/create.injectable"; const applyResourceRouteInjectable = getRouteInjectable({ id: "apply-resource-route", - instantiate: () => clusterRoute({ - method: "post", - path: `${apiPrefix}/stack`, - })(async ({ cluster, payload }) => ({ - response: await new ResourceApplier(cluster).apply(payload), - })), + instantiate: (di) => { + const createK8sResourceApplier = di.inject(createK8sResourceApplierInjectable); + + return clusterRoute({ + method: "post", + path: `${apiPrefix}/stack`, + })(async ({ cluster, payload }) => ({ + response: await createK8sResourceApplier(cluster).apply(payload), + })); + }, }); export default applyResourceRouteInjectable; diff --git a/src/main/routes/resource-applier/patch-resource-route.injectable.ts b/src/main/routes/resource-applier/patch-resource-route.injectable.ts index 53506d6bb0..134a936ba7 100644 --- a/src/main/routes/resource-applier/patch-resource-route.injectable.ts +++ b/src/main/routes/resource-applier/patch-resource-route.injectable.ts @@ -4,10 +4,10 @@ */ import { getRouteInjectable } from "../../router/router.injectable"; import { apiPrefix } from "../../../common/vars"; -import { ResourceApplier } from "../../resource-applier"; import { payloadValidatedClusterRoute } from "../../router/route"; import Joi from "joi"; import type { Patch } from "rfc6902"; +import createK8sResourceApplierInjectable from "../../k8s/resource-applier/create.injectable"; interface PatchResourcePayload { name: string; @@ -40,18 +40,22 @@ const patchResourcePayloadValidator = Joi.object payloadValidatedClusterRoute({ - method: "patch", - path: `${apiPrefix}/stack`, - payloadValidator: patchResourcePayloadValidator, - })(async ({ cluster, payload }) => ({ - response: await new ResourceApplier(cluster).patch( - payload.name, - payload.kind, - payload.patch, - payload.ns, - ), - })), + instantiate: (di) => { + const createK8sResourceApplier = di.inject(createK8sResourceApplierInjectable); + + return payloadValidatedClusterRoute({ + method: "patch", + path: `${apiPrefix}/stack`, + payloadValidator: patchResourcePayloadValidator, + })(async ({ cluster, payload }) => ({ + response: await createK8sResourceApplier(cluster).patch( + payload.name, + payload.kind, + payload.patch, + payload.ns, + ), + })); + }, }); export default patchResourceRouteInjectable; diff --git a/src/renderer/components/delete-cluster-dialog/__tests__/delete-cluster-dialog.test.tsx b/src/renderer/components/delete-cluster-dialog/__tests__/delete-cluster-dialog.test.tsx index 09bf3a0700..21d5620c50 100644 --- a/src/renderer/components/delete-cluster-dialog/__tests__/delete-cluster-dialog.test.tsx +++ b/src/renderer/components/delete-cluster-dialog/__tests__/delete-cluster-dialog.test.tsx @@ -28,6 +28,8 @@ import hotbarStoreInjectable from "../../../../common/hotbars/store.injectable"; import normalizedPlatformInjectable from "../../../../common/vars/normalized-platform.injectable"; import kubectlBinaryNameInjectable from "../../../../main/kubectl/binary-name.injectable"; import kubectlDownloadingNormalizedArchInjectable from "../../../../main/kubectl/normalized-arch.injectable"; +import readFileSyncInjectable from "../../../../common/fs/read-file-sync.injectable"; +import { readFileSync } from "fs"; jest.mock("electron", () => ({ app: { @@ -95,12 +97,11 @@ users: token: kubeconfig-user-q4lm4:xxxyyyy `; -let config: KubeConfig; - describe("", () => { let applicationBuilder: ApplicationBuilder; let createCluster: CreateCluster; let openDeleteClusterDialog: OpenDeleteClusterDialog; + let config: KubeConfig; beforeEach(async () => { applicationBuilder = getApplicationBuilder(); @@ -114,6 +115,7 @@ describe("", () => { rendererDi.override(hotbarStoreInjectable, () => ({})); rendererDi.override(storesAndApisCanBeCreatedInjectable, () => true); + rendererDi.override(readFileSyncInjectable, () => readFileSync); }); const { rendererDi } = applicationBuilder.dis; diff --git a/src/test-utils/expects.ts b/src/test-utils/expects.ts new file mode 100644 index 0000000000..866216f436 --- /dev/null +++ b/src/test-utils/expects.ts @@ -0,0 +1,36 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { inspect } from "util"; + +export interface OnlyOnce { + (value: any): void; + allSatisfied(): void; +} + +export function expectInSetOnce(set: Set): OnlyOnce { + const alreadySeen = new Set(); + const haventSeen = new Set(set); + + return Object.assign( + (value: any) => { + if (haventSeen.has(value)) { + haventSeen.delete(value); + alreadySeen.add(value); + } else if (alreadySeen.has(value)) { + throw new Error(`Expected ${inspect(value)} only once`); + } else { + throw new Error(`Unexpected value ${inspect(value)}`); + } + }, + { + allSatisfied: () => { + if (haventSeen.size) { + throw new Error(`${haventSeen.size} items not seen`); + } + }, + }, + ); +}