diff --git a/src/features/helm-charts/installing-chart/__snapshots__/installing-helm-chart-from-new-tab.test.ts.snap b/src/features/helm-charts/installing-chart/__snapshots__/installing-helm-chart-from-new-tab.test.ts.snap index dee4f06b14..15c98bdca1 100644 --- a/src/features/helm-charts/installing-chart/__snapshots__/installing-helm-chart-from-new-tab.test.ts.snap +++ b/src/features/helm-charts/installing-chart/__snapshots__/installing-helm-chart-from-new-tab.test.ts.snap @@ -11496,7 +11496,21 @@ exports[`installing helm chart from new tab given tab for installing chart was n
- + some-release + + + content_copy + + +
+ Copy +
{ let builder: ApplicationBuilder; @@ -50,6 +52,9 @@ describe("installing helm chart from new tab", () => { callForCreateHelmReleaseMock = asyncFn(); builder.beforeWindowStart((windowDi) => { + windowDi.override(callForHelmReleasesInjectable, () => async () => []); + windowDi.override(callForHelmReleaseDetailsInjectable, () => () => new Promise(() => {})); + windowDi.override( directoryForLensLocalStorageInjectable, () => "/some-directory-for-lens-local-storage", diff --git a/src/main/helm/exec-helm/exec-helm.injectable.ts b/src/main/helm/exec-helm/exec-helm.injectable.ts index a97953706d..97062d22eb 100644 --- a/src/main/helm/exec-helm/exec-helm.injectable.ts +++ b/src/main/helm/exec-helm/exec-helm.injectable.ts @@ -8,14 +8,16 @@ import helmBinaryPathInjectable from "../helm-binary-path.injectable"; import type { AsyncResult } from "../../../common/utils/async-result"; import { getErrorMessage } from "../../../common/utils/get-error-message"; +export type ExecHelm = (...args: string[]) => Promise>; + const execHelmInjectable = getInjectable({ id: "exec-helm", - instantiate: (di) => { + instantiate: (di): ExecHelm => { const execFile = di.inject(execFileInjectable); const helmBinaryPath = di.inject(helmBinaryPathInjectable); - return async (...args: string[]): Promise> => { + return async (...args) => { try { const response = await execFile(helmBinaryPath, args); diff --git a/src/main/helm/helm-release-manager.ts b/src/main/helm/helm-release-manager.ts index f0907dc3c2..29bbbdd830 100644 --- a/src/main/helm/helm-release-manager.ts +++ b/src/main/helm/helm-release-manager.ts @@ -7,10 +7,8 @@ import tempy from "tempy"; import fse from "fs-extra"; import * as yaml from "js-yaml"; import { toCamelCase } from "../../common/utils/camelCase"; -import { execFile } from "child_process"; import { execHelm } from "./exec"; -import assert from "assert"; -import type { JsonObject, JsonValue } from "type-fest"; +import type { JsonValue } from "type-fest"; import { isObject, json } from "../../common/utils"; export async function listReleases(pathToKubeconfig: string, namespace?: string): Promise[]> { @@ -77,55 +75,6 @@ export async function installChart(chart: string, values: JsonValue, name: strin } } -export async function upgradeRelease(name: string, chart: string, values: any, namespace: string, version: string, kubeconfigPath: string, kubectlPath: string) { - const valuesFilePath = tempy.file({ name: "values.yaml" }); - - await fse.writeFile(valuesFilePath, yaml.dump(values)); - - const args = [ - "upgrade", - name, - chart, - "--version", version, - "--values", valuesFilePath, - "--namespace", namespace, - "--kubeconfig", kubeconfigPath, - ]; - - try { - const output = await execHelm(args); - - return { - log: output, - release: await getRelease(name, namespace, kubeconfigPath, kubectlPath), - }; - } finally { - await fse.unlink(valuesFilePath); - } -} - -export async function getRelease(name: string, namespace: string, kubeconfigPath: string, kubectlPath: string) { - const args = [ - "status", - name, - "--namespace", namespace, - "--kubeconfig", kubeconfigPath, - "--output", "json", - ]; - - const release = json.parse(await execHelm(args, { - maxBuffer: 32 * 1024 * 1024 * 1024, // 32 MiB - })); - - if (!isObject(release) || Array.isArray(release)) { - return undefined; - } - - release.resources = await getResources(name, namespace, kubeconfigPath, kubectlPath); - - return release; -} - export async function deleteRelease(name: string, namespace: string, kubeconfigPath: string) { return execHelm([ "delete", @@ -180,62 +129,3 @@ export async function rollback(name: string, namespace: string, revision: number "--kubeconfig", kubeconfigPath, ]); } - -async function getResources(name: string, namespace: string, kubeconfigPath: string, kubectlPath: string) { - const helmArgs = [ - "get", - "manifest", - name, - "--namespace", namespace, - "--kubeconfig", kubeconfigPath, - ]; - const kubectlArgs = [ - "get", - "--kubeconfig", kubeconfigPath, - "-f", "-", - "--output", "json", - // Temporary workaround for https://github.com/lensapp/lens/issues/6031 - // and other potential issues where resources can't be found. Showing - // no resources is better than the app hard-locking, and at least - // the helm metadata is shown. - "--ignore-not-found", - ]; - - try { - const helmOutput = await execHelm(helmArgs); - - return new Promise((resolve, reject) => { - let stdout = ""; - let stderr = ""; - const kubectl = execFile(kubectlPath, kubectlArgs); - - kubectl - .on("exit", (code, signal) => { - if (typeof code === "number") { - if (code === 0) { - if (stdout === "") { - resolve([]); - } else { - const output = json.parse(stdout) as { items: JsonObject[] }; - - resolve(output.items); - } - } else { - reject(stderr); - } - } else { - reject(new Error(`Kubectl exited with signal ${signal}`)); - } - }) - .on("error", reject); - - assert(kubectl.stderr && kubectl.stdout && kubectl.stdin, "For some reason the IO streams are undefined"); - - kubectl.stderr.on("data", output => stderr += output); - kubectl.stdout.on("data", output => stdout += output); - kubectl.stdin.end(helmOutput); - }); - } catch { - return []; - } -} diff --git a/src/main/helm/helm-service/get-helm-release-resources/call-for-helm-manifest/call-for-helm-manifest.injectable.ts b/src/main/helm/helm-service/get-helm-release-resources/call-for-helm-manifest/call-for-helm-manifest.injectable.ts new file mode 100644 index 0000000000..e23966d00d --- /dev/null +++ b/src/main/helm/helm-service/get-helm-release-resources/call-for-helm-manifest/call-for-helm-manifest.injectable.ts @@ -0,0 +1,52 @@ +/** + * 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 { AsyncResult } from "../../../../../common/utils/async-result"; +import execHelmInjectable from "../../../exec-helm/exec-helm.injectable"; +import yaml from "js-yaml"; + +export interface HelmResourceManifest { + metadata: { + namespace: string; + }; +} + +const callForHelmManifestInjectable = getInjectable({ + id: "call-for-helm-manifest", + + instantiate: (di) => { + const execHelm = di.inject(execHelmInjectable); + + return async ( + name: string, + namespace: string, + kubeconfigPath: string, + ): Promise> => { + const result = await execHelm( + "get", + "manifest", + name, + "--namespace", + namespace, + "--kubeconfig", + kubeconfigPath, + ); + + if (!result.callWasSuccessful) { + return { callWasSuccessful: false, error: result.error }; + } + + return { + callWasSuccessful: true, + response: yaml + .loadAll(result.response) + .filter((manifest) => !!manifest) as HelmResourceManifest[], + }; + }; + + }, +}); + +export default callForHelmManifestInjectable; diff --git a/src/main/helm/helm-service/get-helm-release-resources/call-for-kube-resources-by-manifest/call-for-kube-resources-by-manifest.injectable.ts b/src/main/helm/helm-service/get-helm-release-resources/call-for-kube-resources-by-manifest/call-for-kube-resources-by-manifest.injectable.ts new file mode 100644 index 0000000000..c530c98385 --- /dev/null +++ b/src/main/helm/helm-service/get-helm-release-resources/call-for-kube-resources-by-manifest/call-for-kube-resources-by-manifest.injectable.ts @@ -0,0 +1,73 @@ +/** + * 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 { JsonObject } from "type-fest"; +import { json } from "../../../../../common/utils"; +import yaml from "js-yaml"; +import execFileWithInputInjectable from "./exec-file-with-input/exec-file-with-input.injectable"; +import { getErrorMessage } from "../../../../../common/utils/get-error-message"; +import { map } from "lodash/fp"; +import { pipeline } from "@ogre-tools/fp"; +import type { HelmResourceManifest } from "../call-for-helm-manifest/call-for-helm-manifest.injectable"; + +export type CallForKubeResourcesByManifest = ( + namespace: string, + kubeconfigPath: string, + kubectlPath: string, + resourceManifests: HelmResourceManifest[] +) => Promise; + +const callForKubeResourcesByManifestInjectable = getInjectable({ + id: "call-for-kube-resources-by-manifest", + + instantiate: (di): CallForKubeResourcesByManifest => { + const execFileWithInput = di.inject(execFileWithInputInjectable); + + return async ( + namespace, + kubeconfigPath, + kubectlPath, + resourceManifests, + ) => { + const input = pipeline( + resourceManifests, + map((manifest) => yaml.dump(manifest)), + wideJoin("---\n"), + ); + + const result = await execFileWithInput({ + filePath: kubectlPath, + input, + + commandArguments: [ + "get", + "--kubeconfig", + kubeconfigPath, + "-f", + "-", + "--namespace", + namespace, + "--output", + "json", + ], + }); + + if (!result.callWasSuccessful) { + const errorMessage = getErrorMessage(result.error); + + throw new Error(errorMessage); + } + + const output = json.parse(result.response) as { items: JsonObject[] }; + + return output.items; + }; + }, +}); + +export default callForKubeResourcesByManifestInjectable; + +const wideJoin = (joiner: string) => (items: string[]) => + `${joiner}${items.join(joiner)}${joiner}`; diff --git a/src/main/helm/helm-service/get-helm-release-resources/call-for-kube-resources-by-manifest/exec-file-with-input/exec-file-with-input.global-override-for-injectable.ts b/src/main/helm/helm-service/get-helm-release-resources/call-for-kube-resources-by-manifest/exec-file-with-input/exec-file-with-input.global-override-for-injectable.ts new file mode 100644 index 0000000000..70b4771468 --- /dev/null +++ b/src/main/helm/helm-service/get-helm-release-resources/call-for-kube-resources-by-manifest/exec-file-with-input/exec-file-with-input.global-override-for-injectable.ts @@ -0,0 +1,12 @@ +/** + * 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 execFileWithInputInjectable from "./exec-file-with-input.injectable"; + +export default getGlobalOverride(execFileWithInputInjectable, () => () => { + throw new Error( + "Tried to call exec file with input without explicit override", + ); +}); diff --git a/src/main/helm/helm-service/get-helm-release-resources/call-for-kube-resources-by-manifest/exec-file-with-input/exec-file-with-input.injectable.ts b/src/main/helm/helm-service/get-helm-release-resources/call-for-kube-resources-by-manifest/exec-file-with-input/exec-file-with-input.injectable.ts new file mode 100644 index 0000000000..fedcf8ccfe --- /dev/null +++ b/src/main/helm/helm-service/get-helm-release-resources/call-for-kube-resources-by-manifest/exec-file-with-input/exec-file-with-input.injectable.ts @@ -0,0 +1,82 @@ +/** + * 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 { AsyncResult } from "../../../../../../common/utils/async-result"; +import nonPromiseExecFileInjectable from "./non-promise-exec-file.injectable"; +import { isNumber } from "../../../../../../common/utils"; +import assert from "assert"; +import type { ChildProcess } from "child_process"; + +export type ExecFileWithInput = (options: { + filePath: string; + commandArguments: string[]; + input: string; +}) => Promise>; + +const execFileWithInputInjectable = getInjectable({ + id: "exec-file-with-input", + + instantiate: (di): ExecFileWithInput => { + const execFile = di.inject(nonPromiseExecFileInjectable); + + return async ({ filePath, commandArguments, input }) => + new Promise((resolve) => { + let execution: ChildProcess; + + try { + execution = execFile(filePath, commandArguments); + } catch (e) { + resolve({ callWasSuccessful: false, error: e }); + + return; + } + + assert(execution.stdout, "stdout is not defined"); + assert(execution.stderr, "stderr is not defined"); + assert(execution.stdin, "stdin is not defined"); + + let stdout = ""; + let stderr = ""; + + execution.stdout.on("data", (data) => { + stdout += data; + }); + + execution.stderr.on("data", (data) => { + stderr += data; + }); + + execution.on("error", (error) => + resolve({ callWasSuccessful: false, error }), + ); + + execution.on("exit", (code, signal) => { + if (!isNumber(code)) { + resolve({ + callWasSuccessful: false, + error: "Exited without exit code", + }); + + return; + } + + if (code !== 0) { + resolve({ + callWasSuccessful: false, + error: stderr ? stderr : `Failed with error: ${signal}`, + }); + + return; + } + + resolve({ callWasSuccessful: true, response: stdout }); + }); + + execution.stdin.end(input); + }); + }, +}); + +export default execFileWithInputInjectable; diff --git a/src/main/helm/helm-service/get-helm-release-resources/call-for-kube-resources-by-manifest/exec-file-with-input/exec-file-with-input.test.ts b/src/main/helm/helm-service/get-helm-release-resources/call-for-kube-resources-by-manifest/exec-file-with-input/exec-file-with-input.test.ts new file mode 100644 index 0000000000..7a607da5b8 --- /dev/null +++ b/src/main/helm/helm-service/get-helm-release-resources/call-for-kube-resources-by-manifest/exec-file-with-input/exec-file-with-input.test.ts @@ -0,0 +1,210 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getDiForUnitTesting } from "../../../../../getDiForUnitTesting"; +import type { ExecFileWithInput } from "./exec-file-with-input.injectable"; +import execFileWithInputInjectable from "./exec-file-with-input.injectable"; +import type { AsyncResult } from "../../../../../../common/utils/async-result"; +import nonPromiseExecFileInjectable from "./non-promise-exec-file.injectable"; +import { getPromiseStatus } from "../../../../../../common/test-utils/get-promise-status"; +import EventEmitter from "events"; + +describe("exec-file-with-input", () => { + let execFileWithInput: ExecFileWithInput; + let execFileMock: jest.Mock; + + let executionStub: EventEmitter & { + stdin: { end: jest.Mock }; + stdout: EventEmitter; + stderr: EventEmitter; + }; + + beforeEach(() => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + di.unoverride(execFileWithInputInjectable); + + executionStub = new EventEmitter() as any; + executionStub.stdin = { end: jest.fn() }; + executionStub.stdout = new EventEmitter(); + executionStub.stderr = new EventEmitter(); + + execFileMock = jest.fn(() => executionStub); + + di.override(nonPromiseExecFileInjectable, () => execFileMock as any); + + execFileWithInput = di.inject(execFileWithInputInjectable); + }); + + it("given call, when throws synchronously, resolves with failure", async () => { + execFileMock.mockImplementation(() => { + throw new Error("some-error"); + }); + + const actual = await execFileWithInput({ + filePath: "./irrelevant", + commandArguments: ["irrelevant"], + input: "irrelevant", + }); + + expect(actual).toEqual({ + callWasSuccessful: false, + error: expect.any(Error), + }); + }); + + describe("when called", () => { + let actualPromise: Promise>; + + beforeEach(() => { + actualPromise = execFileWithInput({ + filePath: "./some-file-path", + commandArguments: ["some-arg", "some-other-arg"], + input: "some-input", + }); + }); + + it("calls for file with arguments", () => { + expect(execFileMock).toHaveBeenCalledWith("./some-file-path", [ + "some-arg", + "some-other-arg", + ]); + }); + + it("calls with input", () => { + expect(executionStub.stdin.end).toHaveBeenCalledWith("some-input"); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(actualPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + describe("when stdout receives data", () => { + beforeEach(() => { + executionStub.stdout.emit("data", "some-data"); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(actualPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + describe("when stdout receives more data", () => { + beforeEach(() => { + executionStub.stdout.emit("data", "some-other-data"); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(actualPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + it("when execution exits with success, resolves with result", async () => { + executionStub.emit("exit", 0); + + const actual = await actualPromise; + + expect(actual).toEqual({ + callWasSuccessful: true, + response: "some-datasome-other-data", + }); + }); + + it("when execution exits without exit code, resolves with failure", async () => { + executionStub.emit("exit"); + + const actual = await actualPromise; + + expect(actual).toEqual({ + callWasSuccessful: false, + error: "Exited without exit code", + }); + }); + + it("when execution exits with failure, resolves with failure", async () => { + executionStub.emit("exit", 42, "some-signal"); + + const actual = await actualPromise; + + expect(actual).toEqual({ + callWasSuccessful: false, + error: "Failed with error: some-signal", + }); + }); + + describe("when stderr receives data", () => { + beforeEach(() => { + executionStub.stderr.emit("data", "some-error"); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(actualPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + describe("when stderr receives more data", () => { + beforeEach(() => { + executionStub.stderr.emit("data", "some-other-error"); + }); + + it("does not resolve yet", async () => { + const promiseStatus = await getPromiseStatus(actualPromise); + + expect(promiseStatus.fulfilled).toBe(false); + }); + + it("when execution exits with success, resolves with result", async () => { + executionStub.emit("exit", 0); + + const actual = await actualPromise; + + expect(actual).toEqual({ + callWasSuccessful: true, + response: "some-datasome-other-data", + }); + }); + + it("when execution exits without exit code, resolves with failure", async () => { + executionStub.emit("exit"); + + const actual = await actualPromise; + + expect(actual).toEqual({ + callWasSuccessful: false, + error: "Exited without exit code", + }); + }); + + it("when execution exits with failure, resolves with errors", async () => { + executionStub.emit("exit", 42, "irrelevant"); + + const actual = await actualPromise; + + expect(actual).toEqual({ + callWasSuccessful: false, + error: "some-errorsome-other-error", + }); + }); + }); + }); + }); + }); + + it("when execution receives error, resolves with error", async () => { + executionStub.emit("error", new Error("some-error")); + + const actual = await actualPromise; + + expect(actual).toEqual({ + callWasSuccessful: false, + error: expect.any(Error), + }); + }); + }); +}); diff --git a/src/main/helm/helm-service/get-helm-release-resources/call-for-kube-resources-by-manifest/exec-file-with-input/non-promise-exec-file.injectable.ts b/src/main/helm/helm-service/get-helm-release-resources/call-for-kube-resources-by-manifest/exec-file-with-input/non-promise-exec-file.injectable.ts new file mode 100644 index 0000000000..034f5e3055 --- /dev/null +++ b/src/main/helm/helm-service/get-helm-release-resources/call-for-kube-resources-by-manifest/exec-file-with-input/non-promise-exec-file.injectable.ts @@ -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 { execFile } from "child_process"; + +const nonPromiseExecFileInjectable = getInjectable({ + id: "non-promise-exec-file", + instantiate: () => execFile, + causesSideEffects: true, +}); + +export default nonPromiseExecFileInjectable; diff --git a/src/main/helm/helm-service/get-helm-release-resources/get-helm-release-resources.injectable.ts b/src/main/helm/helm-service/get-helm-release-resources/get-helm-release-resources.injectable.ts new file mode 100644 index 0000000000..025aab6b4d --- /dev/null +++ b/src/main/helm/helm-service/get-helm-release-resources/get-helm-release-resources.injectable.ts @@ -0,0 +1,52 @@ +/** + * 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 callForKubeResourcesByManifestInjectable from "./call-for-kube-resources-by-manifest/call-for-kube-resources-by-manifest.injectable"; +import { groupBy, map } from "lodash/fp"; +import type { JsonObject } from "type-fest"; +import { pipeline } from "@ogre-tools/fp"; +import callForHelmManifestInjectable from "./call-for-helm-manifest/call-for-helm-manifest.injectable"; + +export type GetHelmReleaseResources = ( + name: string, + namespace: string, + kubeconfigPath: string, + kubectlPath: string +) => Promise; + +const getHelmReleaseResourcesInjectable = getInjectable({ + id: "get-helm-release-resources", + + instantiate: (di): GetHelmReleaseResources => { + const callForHelmManifest = di.inject(callForHelmManifestInjectable); + const callForKubeResourcesByManifest = di.inject(callForKubeResourcesByManifestInjectable); + + return async (name, namespace, kubeconfigPath, kubectlPath) => { + const result = await callForHelmManifest(name, namespace, kubeconfigPath); + + if (!result.callWasSuccessful) { + throw new Error(result.error); + } + + const results = await pipeline( + result.response, + + groupBy((item) => item.metadata.namespace || namespace), + + (x) => Object.entries(x), + + map(([namespace, manifest]) => + callForKubeResourcesByManifest(namespace, kubeconfigPath, kubectlPath, manifest), + ), + + promises => Promise.all(promises), + ); + + return results.flat(1); + }; + }, +}); + +export default getHelmReleaseResourcesInjectable; diff --git a/src/main/helm/helm-service/get-helm-release-resources/get-helm-release-resources.test.ts b/src/main/helm/helm-service/get-helm-release-resources/get-helm-release-resources.test.ts new file mode 100644 index 0000000000..2ee661a46d --- /dev/null +++ b/src/main/helm/helm-service/get-helm-release-resources/get-helm-release-resources.test.ts @@ -0,0 +1,215 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; +import type { GetHelmReleaseResources } from "./get-helm-release-resources.injectable"; +import getHelmReleaseResourcesInjectable from "./get-helm-release-resources.injectable"; +import type { ExecHelm } from "../../exec-helm/exec-helm.injectable"; +import execHelmInjectable from "../../exec-helm/exec-helm.injectable"; +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import type { JsonObject } from "type-fest"; +import type { ExecFileWithInput } from "./call-for-kube-resources-by-manifest/exec-file-with-input/exec-file-with-input.injectable"; +import execFileWithInputInjectable from "./call-for-kube-resources-by-manifest/exec-file-with-input/exec-file-with-input.injectable"; + +describe("get helm release resources", () => { + let getHelmReleaseResources: GetHelmReleaseResources; + let execHelmMock: AsyncFnMock; + let execFileWithStreamInputMock: AsyncFnMock; + + beforeEach(() => { + const di = getDiForUnitTesting({ doGeneralOverrides: true }); + + execHelmMock = asyncFn(); + execFileWithStreamInputMock = asyncFn(); + + di.override(execHelmInjectable, () => execHelmMock); + + di.override( + execFileWithInputInjectable, + () => execFileWithStreamInputMock, + ); + + getHelmReleaseResources = di.inject(getHelmReleaseResourcesInjectable); + }); + + describe("when called", () => { + let actualPromise: Promise; + + beforeEach(() => { + actualPromise = getHelmReleaseResources( + "some-release", + "some-namespace", + "/some-kubeconfig-path", + "/some-kubectl-path", + ); + }); + + it("calls for release manifest", () => { + expect(execHelmMock).toHaveBeenCalledWith( + "get", "manifest", "some-release", "--namespace", "some-namespace", "--kubeconfig", "/some-kubeconfig-path", + ); + }); + + it("does not call for resources yet", () => { + expect(execFileWithStreamInputMock).not.toHaveBeenCalled(); + }); + + it("when call for manifest resolves without resources, resolves without resources", async () => { + await execHelmMock.resolve({ + callWasSuccessful: true, + response: "", + }); + + const actual = await actualPromise; + + expect(actual).toEqual([]); + }); + + describe("when call for manifest resolves", () => { + beforeEach(async () => { + await execHelmMock.resolve({ + callWasSuccessful: true, + response: `--- +apiVersion: v1 +kind: SomeKind +metadata: + name: some-resource-with-same-namespace + namespace: some-namespace +--- +apiVersion: v1 +kind: SomeKind +metadata: + name: some-resource-without-namespace +--- +apiVersion: v1 +kind: SomeKind +metadata: + name: some-resource-with-different-namespace + namespace: some-other-namespace +--- +`, + }); + }); + + it("calls for resources from each namespace separately using the manifest as input", () => { + expect(execFileWithStreamInputMock.mock.calls).toEqual([ + [ + { + filePath: "/some-kubectl-path", + commandArguments: ["get", "--kubeconfig", "/some-kubeconfig-path", "-f", "-", "--namespace", "some-namespace", "--output", "json"], + input: `--- +apiVersion: v1 +kind: SomeKind +metadata: + name: some-resource-with-same-namespace + namespace: some-namespace +--- +apiVersion: v1 +kind: SomeKind +metadata: + name: some-resource-without-namespace +--- +`, + }, + ], + + [ + { + filePath: "/some-kubectl-path", + commandArguments: ["get", "--kubeconfig", "/some-kubeconfig-path", "-f", "-", "--namespace", "some-other-namespace", "--output", "json"], + input: `--- +apiVersion: v1 +kind: SomeKind +metadata: + name: some-resource-with-different-namespace + namespace: some-other-namespace +--- +`, + }, + ], + ]); + }); + + it("when all calls for resources resolve, resolves with combined result", async () => { + await execFileWithStreamInputMock.resolveSpecific( + ([{ commandArguments }]) => + commandArguments.includes("some-namespace"), + { + callWasSuccessful: true, + + response: JSON.stringify({ + items: [{ some: "item" }], + + kind: "List", + + metadata: { + resourceVersion: "", + selfLink: "", + }, + }), + }, + ); + + await execFileWithStreamInputMock.resolveSpecific( + ([{ commandArguments }]) => + commandArguments.includes("some-other-namespace"), + { + callWasSuccessful: true, + + response: JSON.stringify({ + items: [{ some: "other-item" }], + + kind: "List", + + metadata: { + resourceVersion: "", + selfLink: "", + }, + }), + }, + ); + + const actual = await actualPromise; + + expect(actual).toEqual([{ some: "item" }, { some: "other-item" }]); + }); + + it("given some call fails, when all calls have finished, rejects with failure", async () => { + await execFileWithStreamInputMock.resolveSpecific( + ([{ commandArguments }]) => + commandArguments.includes("some-namespace"), + + { + callWasSuccessful: true, + + response: JSON.stringify({ + items: [{ some: "item" }], + + kind: "List", + + metadata: { + resourceVersion: "", + selfLink: "", + }, + }), + }, + ); + + execFileWithStreamInputMock.resolveSpecific( + ([{ commandArguments }]) => + commandArguments.includes("some-other-namespace"), + + { + callWasSuccessful: false, + error: "some-error", + }, + ); + + return expect(actualPromise).rejects.toEqual(expect.any(Error)); + }); + }); + }); +}); diff --git a/src/main/helm/helm-service/get-helm-release.injectable.ts b/src/main/helm/helm-service/get-helm-release.injectable.ts index 130d94af52..79177415a2 100644 --- a/src/main/helm/helm-service/get-helm-release.injectable.ts +++ b/src/main/helm/helm-service/get-helm-release.injectable.ts @@ -4,14 +4,17 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import type { Cluster } from "../../../common/cluster/cluster"; -import { getRelease } from "../helm-release-manager"; import loggerInjectable from "../../../common/logger.injectable"; +import { isObject, json } from "../../../common/utils"; +import { execHelm } from "../exec"; +import getHelmReleaseResourcesInjectable from "./get-helm-release-resources/get-helm-release-resources.injectable"; const getHelmReleaseInjectable = getInjectable({ id: "get-helm-release", instantiate: (di) => { const logger = di.inject(loggerInjectable); + const getHelmReleaseResources = di.inject(getHelmReleaseResourcesInjectable); return async (cluster: Cluster, releaseName: string, namespace: string) => { const kubeconfigPath = await cluster.getProxyKubeconfigPath(); @@ -20,7 +23,35 @@ const getHelmReleaseInjectable = getInjectable({ logger.debug("Fetch release"); - return getRelease(releaseName, namespace, kubeconfigPath, kubectlPath); + const args = [ + "status", + releaseName, + "--namespace", + namespace, + "--kubeconfig", + kubeconfigPath, + "--output", + "json", + ]; + + const release = json.parse( + await execHelm(args, { + maxBuffer: 32 * 1024 * 1024 * 1024, // 32 MiB + }), + ); + + if (!isObject(release) || Array.isArray(release)) { + return undefined; + } + + release.resources = await getHelmReleaseResources( + releaseName, + namespace, + kubeconfigPath, + kubectlPath, + ); + + return release; }; }, diff --git a/src/main/helm/helm-service/update-helm-release.injectable.ts b/src/main/helm/helm-service/update-helm-release.injectable.ts index 96e0e0050b..e07269ce6a 100644 --- a/src/main/helm/helm-service/update-helm-release.injectable.ts +++ b/src/main/helm/helm-service/update-helm-release.injectable.ts @@ -4,9 +4,13 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import type { Cluster } from "../../../common/cluster/cluster"; -import { upgradeRelease } from "../helm-release-manager"; import loggerInjectable from "../../../common/logger.injectable"; import type { JsonObject } from "type-fest"; +import { execHelm } from "../exec"; +import tempy from "tempy"; +import fse from "fs-extra"; +import yaml from "js-yaml"; +import getHelmReleaseInjectable from "./get-helm-release.injectable"; export interface UpdateChartArgs { chart: string; @@ -19,23 +23,37 @@ const updateHelmReleaseInjectable = getInjectable({ instantiate: (di) => { const logger = di.inject(loggerInjectable); + const getHelmRelease = di.inject(getHelmReleaseInjectable); return async (cluster: Cluster, releaseName: string, namespace: string, data: UpdateChartArgs) => { const proxyKubeconfig = await cluster.getProxyKubeconfigPath(); - const kubectl = await cluster.ensureKubectl(); - const kubectlPath = await kubectl.getPath(); logger.debug("Upgrade release"); - return upgradeRelease( + const valuesFilePath = tempy.file({ name: "values.yaml" }); + + await fse.writeFile(valuesFilePath, yaml.dump(data.values)); + + const args = [ + "upgrade", releaseName, data.chart, - data.values, - namespace, - data.version, - proxyKubeconfig, - kubectlPath, - ); + "--version", data.version, + "--values", valuesFilePath, + "--namespace", namespace, + "--kubeconfig", proxyKubeconfig, + ]; + + try { + const output = await execHelm(args); + + return { + log: output, + release: await getHelmRelease(cluster, releaseName, namespace), + }; + } finally { + await fse.unlink(valuesFilePath); + } }; }, @@ -43,3 +61,4 @@ const updateHelmReleaseInjectable = getInjectable({ }); export default updateHelmReleaseInjectable; +