1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Stop using HelmCli from Renderer (#4861)

* Introduce way for execute file

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Make typing of HelmRepo shared

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Introduce way to get Helm environment values

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Introduce function to read YAML file

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Introduce competition for listing active helm repositories in preferences

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Make sense in name of injectable

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Introduce helper for opening and selecting values of select

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Introduce competition for activating, deactivating public helm repositories in preferences

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Introduce competition for deactivating helm repository from list of active repositories

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Add missing global overrides

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Make some tests more deterministic by mocking tooltips

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Introduce competition for activating custom helm repository in preferences

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Update snapshots

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Remove old implementation made redundant with competition for preferences of helm repositories

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Add success notification for activating custom helm repository

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Introduce way to get single active helm repository

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Extract responsibilities from god-class and switch to getting helm repositories using competition instead of another god class

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Remove dead code

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Add TODO

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Tweak position of spinner

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Start handling errors when accessing helm repositories

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Handle error about no helm repositories when updating repositories

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Remove unwarranted function configuration

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Add missing global overrides

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Remove duplication how to acquire binary path for helm

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Remove redundant comment

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Consolidate naming to match Helm's internal

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Relocate file closer to it's relatives

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>
This commit is contained in:
Janne Savolainen 2022-06-13 11:42:53 +03:00 committed by GitHub
parent 5420780ae0
commit 1393cc303d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
99 changed files with 21409 additions and 1039 deletions

View File

@ -0,0 +1,387 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { RenderResult } from "@testing-library/react";
import { fireEvent } from "@testing-library/react";
import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder";
import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder";
import type { AsyncFnMock } from "@async-fn/jest";
import asyncFn from "@async-fn/jest";
import execFileInjectable from "../../common/fs/exec-file.injectable";
import helmBinaryPathInjectable from "../../main/helm/helm-binary-path.injectable";
import getActiveHelmRepositoriesInjectable from "../../main/helm/repositories/get-active-helm-repositories/get-active-helm-repositories.injectable";
import type { HelmRepo } from "../../common/helm/helm-repo";
import callForPublicHelmRepositoriesInjectable from "../../renderer/components/+preferences/kubernetes/helm-charts/adding-of-public-helm-repository/public-helm-repositories/call-for-public-helm-repositories.injectable";
import isPathInjectable from "../../renderer/components/input/validators/is-path.injectable";
import showSuccessNotificationInjectable from "../../renderer/components/notifications/show-success-notification.injectable";
import showErrorNotificationInjectable from "../../renderer/components/notifications/show-error-notification.injectable";
import type { AsyncResult } from "../../common/utils/async-result";
// TODO: Make tooltips free of side effects by making it deterministic
jest.mock("../../renderer/components/tooltip/withTooltip", () => ({
withTooltip: (target: any) => target,
}));
describe("add custom helm repository in preferences", () => {
let applicationBuilder: ApplicationBuilder;
let showSuccessNotificationMock: jest.Mock;
let showErrorNotificationMock: jest.Mock;
let rendered: RenderResult;
let execFileMock: AsyncFnMock<
ReturnType<typeof execFileInjectable["instantiate"]>
>;
let getActiveHelmRepositoriesMock: AsyncFnMock<() => AsyncResult<HelmRepo[]>>;
beforeEach(async () => {
jest.useFakeTimers("modern");
applicationBuilder = getApplicationBuilder();
execFileMock = asyncFn();
getActiveHelmRepositoriesMock = asyncFn();
applicationBuilder.beforeApplicationStart(({ mainDi, rendererDi }) => {
rendererDi.override(callForPublicHelmRepositoriesInjectable, () => async () => []);
showSuccessNotificationMock = jest.fn();
rendererDi.override(showSuccessNotificationInjectable, () => showSuccessNotificationMock);
showErrorNotificationMock = jest.fn();
rendererDi.override(showErrorNotificationInjectable, () => showErrorNotificationMock);
// TODO: Figure out how to make async validators unit testable
rendererDi.override(isPathInjectable, () => ({ debounce: 0, validate: async () => {} }));
mainDi.override(
getActiveHelmRepositoriesInjectable,
() => getActiveHelmRepositoriesMock,
);
mainDi.override(execFileInjectable, () => execFileMock);
mainDi.override(helmBinaryPathInjectable, () => "some-helm-binary-path");
});
rendered = await applicationBuilder.render();
});
describe("when navigating to preferences containing helm repositories", () => {
beforeEach(async () => {
applicationBuilder.preferences.navigate();
applicationBuilder.preferences.navigation.click("kubernetes");
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
describe("when active repositories resolve", () => {
beforeEach(async () => {
await Promise.all([
getActiveHelmRepositoriesMock.resolve({
callWasSuccessful: true,
response: [
{ name: "Some active repository", url: "some-url" },
],
}),
]);
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
describe("when selecting to add custom repository", () => {
beforeEach(() => {
const button = rendered.getByTestId("add-custom-helm-repo-button");
fireEvent.click(button);
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("shows dialog", () => {
expect(
rendered.queryByTestId("add-custom-helm-repository-dialog"),
).toBeInTheDocument();
});
// TODO: Figure out how to close dialog by clicking outside of it
xdescribe("when closing the dialog by clicking outside", () => {
beforeEach(() => {});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("does not show dialog anymore", () => {
expect(
rendered.queryByTestId("add-custom-helm-repository-dialog"),
).not.toBeInTheDocument();
});
});
describe("when closing the dialog by clicking cancel", () => {
beforeEach(() => {
const button = rendered.getByTestId("custom-helm-repository-cancel-button");
fireEvent.click(button);
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("does not show dialog anymore", () => {
expect(
rendered.queryByTestId("add-custom-helm-repository-dialog"),
).not.toBeInTheDocument();
});
});
describe("when inputted minimal options for the repository", () => {
beforeEach(() => {
getActiveHelmRepositoriesMock.mockClear();
const nameInput = rendered.getByTestId("custom-helm-repository-name-input");
fireEvent.change(nameInput, { target: { value: "some-custom-repository" }});
const urlInput = rendered.getByTestId("custom-helm-repository-url-input");
fireEvent.change(urlInput, { target: { value: "http://some.url" }});
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
describe("when submitted and some time passes", () => {
beforeEach(() => {
const submitButton = rendered.getByTestId("custom-helm-repository-submit-button");
fireEvent.click(submitButton);
// TODO: Remove when debounce is removed from WizardStep.submit
jest.runOnlyPendingTimers();
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("adds the repository", () => {
expect(execFileMock).toHaveBeenCalledWith(
"some-helm-binary-path",
["repo", "add", "some-custom-repository", "http://some.url"],
);
});
it("does not reload active repositories yet", () => {
expect(getActiveHelmRepositoriesMock).not.toHaveBeenCalled();
});
it("does not show notification yet", () => {
expect(showSuccessNotificationMock).not.toHaveBeenCalled();
});
describe("when activation rejects", () => {
beforeEach(async () => {
await execFileMock.reject(
"Some error",
);
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("shows error notification", () => {
expect(showErrorNotificationMock).toHaveBeenCalledWith(
"Some error",
);
});
it("does not show success notification", () => {
expect(showSuccessNotificationMock).not.toHaveBeenCalled();
});
it("does not show dialog anymore", () => {
expect(rendered.queryByTestId("add-custom-helm-repository-dialog")).not.toBeInTheDocument();
});
it("does not reload active repositories", () => {
expect(getActiveHelmRepositoriesMock).not.toHaveBeenCalled();
});
});
describe("when activation resolves with success", () => {
beforeEach(async () => {
await execFileMock.resolveSpecific(
[
"some-helm-binary-path",
["repo", "add", "some-custom-repository", "http://some.url"],
],
"",
);
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("does not show dialog anymore", () => {
expect(rendered.queryByTestId("add-custom-helm-repository-dialog")).not.toBeInTheDocument();
});
it("reloads active repositories", () => {
expect(getActiveHelmRepositoriesMock).toHaveBeenCalled();
});
it("shows success notification", () => {
expect(showSuccessNotificationMock).toHaveBeenCalledWith(
"Helm repository some-custom-repository has been added.",
);
});
describe("when adding custom repository again", () => {
beforeEach(() => {
const button = rendered.getByTestId("add-custom-helm-repo-button");
fireEvent.click(button);
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("repository name is empty", () => {
const input = rendered.getByTestId("custom-helm-repository-name-input") as HTMLInputElement;
expect(input.value).toBe("");
});
it("repository url is empty", () => {
const input = rendered.getByTestId("custom-helm-repository-url-input") as HTMLInputElement;
expect(input.value).toBe("");
});
});
});
});
describe("when showing the maximal options", () => {
beforeEach(() => {
const button = rendered.getByTestId("toggle-maximal-options-for-custom-helm-repository-button");
fireEvent.click(button);
});
it("shows maximal options", () => {
const maximalOptions = rendered.getByTestId("maximal-options-for-custom-helm-repository-dialog");
expect(maximalOptions).toBeInTheDocument();
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("given closing the dialog, when reopening the dialog, still shows maximal options", () => {
const cancelButton = rendered.getByTestId("custom-helm-repository-cancel-button");
fireEvent.click(cancelButton);
const openButton = rendered.getByTestId("add-custom-helm-repo-button");
fireEvent.click(openButton);
const maximalOptions = rendered.getByTestId("maximal-options-for-custom-helm-repository-dialog");
expect(maximalOptions).toBeInTheDocument();
});
describe("when hiding maximal options", () => {
beforeEach(() => {
const button = rendered.getByTestId("toggle-maximal-options-for-custom-helm-repository-button");
fireEvent.click(button);
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("does not show maximal options anymore", () => {
const maximalOptions = rendered.queryByTestId("maximal-options-for-custom-helm-repository-dialog");
expect(maximalOptions).not.toBeInTheDocument();
});
});
describe("when inputted maximal options", () => {
beforeEach(async () => {
[
{ selector: "username-input", value: "some-username" },
{ selector: "password-input", value: "some-password" },
{ selector: "ca-cert-file-input", value: "some-ca-cert-file" },
{ selector: "cert-file-input", value: "some-cert-file" },
{ selector: "key-file-input", value: "some-key-file" },
].forEach(({ selector, value }) => {
const input = rendered.getByTestId(`custom-helm-repository-${selector}`);
fireEvent.change(input, { target: { value }});
});
const checkbox = rendered.getByTestId(`custom-helm-repository-verify-tls-input`);
fireEvent.click(checkbox);
jest.runOnlyPendingTimers();
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("when submitted and some time passes, adds the repository with maximal options", () => {
const submitButton = rendered.getByTestId("custom-helm-repository-submit-button");
fireEvent.click(submitButton);
// TODO: Remove when debounce is removed from WizardStep.submit
jest.runOnlyPendingTimers();
expect(execFileMock).toHaveBeenCalledWith(
"some-helm-binary-path",
[
"repo",
"add",
"some-custom-repository",
"http://some.url",
"--insecure-skip-tls-verify",
"--username",
"some-username",
"--password",
"some-password",
"--ca-file",
"some-ca-cert-file",
"--key-file",
"some-key-file",
"--cert-file",
"some-cert-file",
],
);
});
});
});
});
});
});
});
});

View File

@ -0,0 +1,278 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { RenderResult } from "@testing-library/react";
import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder";
import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder";
import type { AsyncFnMock } from "@async-fn/jest";
import asyncFn from "@async-fn/jest";
import execFileInjectable from "../../common/fs/exec-file.injectable";
import helmBinaryPathInjectable from "../../main/helm/helm-binary-path.injectable";
import getActiveHelmRepositoriesInjectable from "../../main/helm/repositories/get-active-helm-repositories/get-active-helm-repositories.injectable";
import type { HelmRepo } from "../../common/helm/helm-repo";
import callForPublicHelmRepositoriesInjectable from "../../renderer/components/+preferences/kubernetes/helm-charts/adding-of-public-helm-repository/public-helm-repositories/call-for-public-helm-repositories.injectable";
import showSuccessNotificationInjectable from "../../renderer/components/notifications/show-success-notification.injectable";
import showErrorNotificationInjectable from "../../renderer/components/notifications/show-error-notification.injectable";
import type { AsyncResult } from "../../common/utils/async-result";
// TODO: Make tooltips free of side effects by making it deterministic
jest.mock("../../renderer/components/tooltip/withTooltip", () => ({
withTooltip: (target: any) => target,
}));
describe("add helm repository from list in preferences", () => {
let applicationBuilder: ApplicationBuilder;
let showSuccessNotificationMock: jest.Mock;
let showErrorNotificationMock: jest.Mock;
let rendered: RenderResult;
let execFileMock: AsyncFnMock<
ReturnType<typeof execFileInjectable["instantiate"]>
>;
let getActiveHelmRepositoriesMock: AsyncFnMock<() => AsyncResult<HelmRepo[]>>;
let callForPublicHelmRepositoriesMock: AsyncFnMock<() => Promise<HelmRepo[]>>;
beforeEach(async () => {
applicationBuilder = getApplicationBuilder();
execFileMock = asyncFn();
getActiveHelmRepositoriesMock = asyncFn();
callForPublicHelmRepositoriesMock = asyncFn();
applicationBuilder.beforeApplicationStart(({ mainDi, rendererDi }) => {
showSuccessNotificationMock = jest.fn();
rendererDi.override(showSuccessNotificationInjectable, () => showSuccessNotificationMock);
showErrorNotificationMock = jest.fn();
rendererDi.override(showErrorNotificationInjectable, () => showErrorNotificationMock);
rendererDi.override(
callForPublicHelmRepositoriesInjectable,
() => callForPublicHelmRepositoriesMock,
);
mainDi.override(
getActiveHelmRepositoriesInjectable,
() => getActiveHelmRepositoriesMock,
);
mainDi.override(execFileInjectable, () => execFileMock);
mainDi.override(helmBinaryPathInjectable, () => "some-helm-binary-path");
});
rendered = await applicationBuilder.render();
});
describe("when navigating to preferences containing helm repositories", () => {
beforeEach(async () => {
applicationBuilder.preferences.navigate();
applicationBuilder.preferences.navigation.click("kubernetes");
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("calls for public repositories", () => {
expect(callForPublicHelmRepositoriesMock).toHaveBeenCalled();
});
it("calls for active repositories", () => {
expect(getActiveHelmRepositoriesMock).toHaveBeenCalled();
});
describe("when both active and public repositories resolve", () => {
beforeEach(async () => {
await Promise.all([
callForPublicHelmRepositoriesMock.resolve([
{ name: "Some already active repository", url: "some-url" },
{ name: "Some to be added repository", url: "some-other-url" },
]),
getActiveHelmRepositoriesMock.resolve({
callWasSuccessful: true,
response: [
{ name: "Some already active repository", url: "some-url" },
],
}),
]);
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
describe("when select for adding public repositories is clicked", () => {
beforeEach(() => {
applicationBuilder.select.openMenu(
"selection-of-active-public-helm-repository",
);
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
describe("when deactive public repository is selected", () => {
beforeEach(async () => {
getActiveHelmRepositoriesMock.mockClear();
applicationBuilder.select.selectOption(
"selection-of-active-public-helm-repository",
"Some to be added repository",
);
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("adds the repository", () => {
expect(execFileMock).toHaveBeenCalledWith(
"some-helm-binary-path",
["repo", "add", "Some to be added repository", "some-other-url"],
);
});
it("does not reload active repositories yet", () => {
expect(getActiveHelmRepositoriesMock).not.toHaveBeenCalled();
});
describe("when adding rejects", () => {
beforeEach(async () => {
await execFileMock.reject(
"Some error",
);
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("shows error notification", () => {
expect(showErrorNotificationMock).toHaveBeenCalledWith(
"Some error",
);
});
it("does not show success notification", () => {
expect(showSuccessNotificationMock).not.toHaveBeenCalled();
});
it("does not show dialog anymore", () => {
expect(rendered.queryByTestId("add-custom-helm-repository-dialog")).not.toBeInTheDocument();
});
it("does not reload active repositories", () => {
expect(getActiveHelmRepositoriesMock).not.toHaveBeenCalled();
});
});
describe("when adding resolves", () => {
beforeEach(async () => {
await execFileMock.resolveSpecific(
[
"some-helm-binary-path",
["repo", "add", "Some to be added repository", "some-other-url"],
],
"",
);
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("reloads active repositories", () => {
expect(getActiveHelmRepositoriesMock).toHaveBeenCalled();
});
it("shows success notification", () => {
expect(showSuccessNotificationMock).toHaveBeenCalledWith(
"Helm repository Some to be added repository has been added.",
);
});
describe("when active repositories resolve again", () => {
beforeEach(async () => {
await getActiveHelmRepositoriesMock.resolve({
callWasSuccessful: true,
response: [
{ name: "Some already active repository", url: "some-url" },
{ name: "Some to be added repository", url: "some-other-url" },
],
});
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
describe("when select for selecting active repositories is clicked", () => {
beforeEach(() => {
applicationBuilder.select.openMenu(
"selection-of-active-public-helm-repository",
);
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
describe("when active repository is selected", () => {
beforeEach(() => {
execFileMock.mockClear();
getActiveHelmRepositoriesMock.mockClear();
applicationBuilder.select.selectOption(
"selection-of-active-public-helm-repository",
"Some already active repository",
);
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("removes the repository", () => {
expect(execFileMock).toHaveBeenCalledWith(
"some-helm-binary-path",
["repo", "remove", "Some already active repository"],
);
});
it("does not reload active repositories yet", () => {
expect(getActiveHelmRepositoriesMock).not.toHaveBeenCalled();
});
describe("when removing resolves", () => {
beforeEach(async () => {
await execFileMock.resolveSpecific(
[
"some-helm-binary-path",
["repo", "remove", "Some already active repository"],
],
"",
);
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("reloads active repositories", () => {
expect(getActiveHelmRepositoriesMock).toHaveBeenCalled();
});
});
});
});
});
});
});
});
});
});
});

View File

@ -0,0 +1,458 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { RenderResult } from "@testing-library/react";
import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder";
import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder";
import type { ReadYamlFile } from "../../common/fs/read-yaml-file.injectable";
import readYamlFileInjectable from "../../common/fs/read-yaml-file.injectable";
import type { AsyncFnMock } from "@async-fn/jest";
import asyncFn from "@async-fn/jest";
import type { HelmRepositoriesFromYaml } from "../../main/helm/repositories/get-active-helm-repositories/get-active-helm-repositories.injectable";
import execFileInjectable from "../../common/fs/exec-file.injectable";
import helmBinaryPathInjectable from "../../main/helm/helm-binary-path.injectable";
import loggerInjectable from "../../common/logger.injectable";
import type { Logger } from "../../common/logger";
import callForPublicHelmRepositoriesInjectable from "../../renderer/components/+preferences/kubernetes/helm-charts/adding-of-public-helm-repository/public-helm-repositories/call-for-public-helm-repositories.injectable";
import showErrorNotificationInjectable from "../../renderer/components/notifications/show-error-notification.injectable";
// TODO: Make tooltips free of side effects by making it deterministic
jest.mock("../../renderer/components/tooltip/withTooltip", () => ({
withTooltip: (target: any) => target,
}));
describe("listing active helm repositories in preferences", () => {
let applicationBuilder: ApplicationBuilder;
let rendered: RenderResult;
let readYamlFileMock: AsyncFnMock<ReadYamlFile>;
let execFileMock: AsyncFnMock<ReturnType<typeof execFileInjectable["instantiate"]>>;
let loggerStub: Logger;
let showErrorNotificationMock: jest.Mock;
beforeEach(async () => {
applicationBuilder = getApplicationBuilder();
readYamlFileMock = asyncFn();
execFileMock = asyncFn();
loggerStub = { error: jest.fn() } as unknown as Logger;
applicationBuilder.beforeApplicationStart(({ mainDi, rendererDi }) => {
showErrorNotificationMock = jest.fn();
rendererDi.override(showErrorNotificationInjectable, () => showErrorNotificationMock);
rendererDi.override(callForPublicHelmRepositoriesInjectable, () => async () => []);
mainDi.override(readYamlFileInjectable, () => readYamlFileMock);
mainDi.override(execFileInjectable, () => execFileMock);
mainDi.override(helmBinaryPathInjectable, () => "some-helm-binary-path");
mainDi.override(loggerInjectable, () => loggerStub);
});
rendered = await applicationBuilder.render();
});
describe("when navigating to preferences containing helm repositories", () => {
beforeEach(async () => {
applicationBuilder.preferences.navigate();
applicationBuilder.preferences.navigation.click("kubernetes");
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("shows loader for repositories", () => {
expect(
rendered.getByTestId("helm-repositories-are-loading"),
).toBeInTheDocument();
});
it("calls for helm configuration", () => {
expect(execFileMock).toHaveBeenCalledWith(
"some-helm-binary-path",
["env"],
);
});
it("does not call for updating of repositories yet", () => {
expect(execFileMock).not.toHaveBeenCalledWith(
"some-helm-binary-path",
["repo", "update"],
);
});
describe("when getting configuration rejects", () => {
beforeEach(async () => {
await execFileMock.reject("some-error");
});
it("shows error notification", () => {
expect(showErrorNotificationMock).toHaveBeenCalledWith(
"Error getting Helm configuration: some-error",
);
});
it("removes all helm controls", () => {
expect(
rendered.queryByTestId("helm-controls"),
).not.toBeInTheDocument();
});
it("does not show loader for repositories anymore", () => {
expect(
rendered.queryByTestId("helm-repositories-are-loading"),
).not.toBeInTheDocument();
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
});
describe("when configuration resolves without path to repository config file", () => {
beforeEach(async () => {
execFileMock.mockClear();
await execFileMock.resolveSpecific(
["some-helm-binary-path", ["env"]],
"HELM_REPOSITORY_CACHE=some-helm-repository-cache-path",
);
});
it("logs error", () => {
expect(loggerStub.error).toHaveBeenCalledWith(
"Tried to get Helm repositories, but HELM_REPOSITORY_CONFIG was not present in `$ helm env`.",
);
});
it("shows error notification", () => {
expect(showErrorNotificationMock).toHaveBeenCalledWith(
"Error getting Helm configuration: Tried to get Helm repositories, but HELM_REPOSITORY_CONFIG was not present in `$ helm env`.",
);
});
it("removes all helm controls", () => {
expect(
rendered.queryByTestId("helm-controls"),
).not.toBeInTheDocument();
});
it("does not show loader for repositories anymore", () => {
expect(
rendered.queryByTestId("helm-repositories-are-loading"),
).not.toBeInTheDocument();
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
});
describe("when configuration resolves without path to repository cache directory", () => {
beforeEach(async () => {
execFileMock.mockClear();
await execFileMock.resolveSpecific(
["some-helm-binary-path", ["env"]],
"HELM_REPOSITORY_CONFIG=some-helm-repository-config-file.yaml",
);
});
it("logs error", () => {
expect(loggerStub.error).toHaveBeenCalledWith(
"Tried to get Helm repositories, but HELM_REPOSITORY_CACHE was not present in `$ helm env`.",
);
});
it("shows error notification", () => {
expect(showErrorNotificationMock).toHaveBeenCalledWith(
"Error getting Helm configuration: Tried to get Helm repositories, but HELM_REPOSITORY_CACHE was not present in `$ helm env`.",
);
});
it("removes all helm controls", () => {
expect(
rendered.queryByTestId("helm-controls"),
).not.toBeInTheDocument();
});
it("does not show loader for repositories anymore", () => {
expect(
rendered.queryByTestId("helm-repositories-are-loading"),
).not.toBeInTheDocument();
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
});
describe("when configuration resolves", () => {
beforeEach(async () => {
execFileMock.mockClear();
await execFileMock.resolveSpecific(
["some-helm-binary-path", ["env"]],
[
"HELM_REPOSITORY_CONFIG=some-helm-repository-config-file.yaml",
"HELM_REPOSITORY_CACHE=some-helm-repository-cache-path",
].join("\n"),
);
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("calls for update of repositories", () => {
expect(execFileMock).toHaveBeenCalledWith(
"some-helm-binary-path",
["repo", "update"],
);
});
it("does not call for repositories yet", () => {
expect(readYamlFileMock).not.toHaveBeenCalled();
});
describe("when updating repositories reject with any other error", () => {
beforeEach(async () => {
await execFileMock.reject("Some error");
});
it("shows error notification", () => {
expect(showErrorNotificationMock).toHaveBeenCalledWith(
"Error updating Helm repositories: Some error",
);
});
it("removes all helm controls", () => {
expect(
rendered.queryByTestId("helm-controls"),
).not.toBeInTheDocument();
});
it("does not show loader for repositories anymore", () => {
expect(
rendered.queryByTestId("helm-repositories-are-loading"),
).not.toBeInTheDocument();
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
});
describe("when updating repositories reject with error about no existing repositories", () => {
beforeEach(async () => {
execFileMock.mockClear();
await execFileMock.reject(
"Error: no repositories found. You must add one before updating",
);
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("still shows the loader for repositories", () => {
expect(
rendered.queryByTestId("helm-repositories-are-loading"),
).toBeInTheDocument();
});
it('adds "bitnami" as default repository', () => {
expect(execFileMock).toHaveBeenCalledWith(
"some-helm-binary-path",
["repo", "add", "bitnami", "https://charts.bitnami.com/bitnami"],
);
});
describe("when adding default repository reject", () => {
beforeEach(async () => {
await execFileMock.reject("Some error");
});
it("shows error notification", () => {
expect(showErrorNotificationMock).toHaveBeenCalledWith(
"Error when adding default Helm repository: Some error",
);
});
it("removes all helm controls", () => {
expect(
rendered.queryByTestId("helm-controls"),
).not.toBeInTheDocument();
});
it("does not show loader for repositories anymore", () => {
expect(
rendered.queryByTestId("helm-repositories-are-loading"),
).not.toBeInTheDocument();
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
});
describe("when adding of default repository resolves", () => {
beforeEach(async () => {
readYamlFileMock.mockClear();
await execFileMock.resolveSpecific(
[
"some-helm-binary-path",
[
"repo",
"add",
"bitnami",
"https://charts.bitnami.com/bitnami",
],
],
"",
);
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("still shows the loader for repositories", () => {
expect(
rendered.queryByTestId("helm-repositories-are-loading"),
).toBeInTheDocument();
});
it("calls for repositories again", () => {
expect(readYamlFileMock).toHaveBeenCalledWith(
"some-helm-repository-config-file.yaml",
);
});
describe("when another call for repositories resolve", () => {
beforeEach(async () => {
await readYamlFileMock.resolveSpecific(
["some-helm-repository-config-file.yaml"],
{
repositories: [
{
name: "bitnami",
url: "https://charts.bitnami.com/bitnami",
caFile: "irrelevant",
certFile: "irrelevant",
insecure_skip_tls_verify: false,
keyFile: "irrelevant",
pass_credentials_all: false,
password: "irrelevant",
username: "irrelevant",
},
],
},
);
});
it("does not show loader for repositories anymore", () => {
expect(
rendered.queryByTestId("helm-repositories-are-loading"),
).not.toBeInTheDocument();
});
it("shows the added repository", () => {
const actual = rendered.getByTestId("helm-repository-bitnami");
expect(actual).toBeInTheDocument();
});
});
});
});
describe("when updating repositories resolve", () => {
beforeEach(async () => {
execFileMock.mockClear();
await execFileMock.resolveSpecific(
["some-helm-binary-path", ["repo", "update"]],
"",
);
});
it("loads repositories from file system", () => {
expect(readYamlFileMock).toHaveBeenCalledWith(
"some-helm-repository-config-file.yaml",
);
});
describe("when repositories resolves", () => {
beforeEach(async () => {
execFileMock.mockClear();
await readYamlFileMock.resolveSpecific(
["some-helm-repository-config-file.yaml"],
repositoryConfigStub,
);
});
it("does not add default repository", () => {
expect(execFileMock).not.toHaveBeenCalledWith(
"some-helm-binary-path",
["repo", "add", "bitnami", "https://charts.bitnami.com/bitnami"],
);
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("does not show loader for repositories anymore", () => {
expect(
rendered.queryByTestId("helm-repositories-are-loading"),
).not.toBeInTheDocument();
});
it("shows repositories in use", () => {
const actual = rendered.getAllByTestId(
/^helm-repository-(some-repository|some-other-repository)$/,
);
expect(actual).toHaveLength(2);
});
});
});
});
});
});
const repositoryConfigStub: HelmRepositoriesFromYaml = {
repositories: [
{
name: "some-repository",
url: "some-repository-url",
caFile: "irrelevant",
certFile: "irrelevant",
insecure_skip_tls_verify: false,
keyFile: "irrelevant",
pass_credentials_all: false,
password: "irrelevant",
username: "irrelevant",
},
{
name: "some-other-repository",
url: "some-other-repository-url",
caFile: "irrelevant",
certFile: "irrelevant",
insecure_skip_tls_verify: false,
keyFile: "irrelevant",
pass_credentials_all: false,
password: "irrelevant",
username: "irrelevant",
},
],
};

View File

@ -0,0 +1,126 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { RenderResult } from "@testing-library/react";
import { fireEvent } from "@testing-library/react";
import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder";
import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder";
import type { AsyncFnMock } from "@async-fn/jest";
import asyncFn from "@async-fn/jest";
import execFileInjectable from "../../common/fs/exec-file.injectable";
import helmBinaryPathInjectable from "../../main/helm/helm-binary-path.injectable";
import getActiveHelmRepositoriesInjectable from "../../main/helm/repositories/get-active-helm-repositories/get-active-helm-repositories.injectable";
import type { HelmRepo } from "../../common/helm/helm-repo";
import callForPublicHelmRepositoriesInjectable from "../../renderer/components/+preferences/kubernetes/helm-charts/adding-of-public-helm-repository/public-helm-repositories/call-for-public-helm-repositories.injectable";
import type { AsyncResult } from "../../common/utils/async-result";
// TODO: Make tooltips free of side effects by making it deterministic
jest.mock("../../renderer/components/tooltip/withTooltip", () => ({
withTooltip: (target: any) => target,
}));
describe("remove helm repository from list of active repositories in preferences", () => {
let applicationBuilder: ApplicationBuilder;
let rendered: RenderResult;
let getActiveHelmRepositoriesMock: AsyncFnMock<() => AsyncResult<HelmRepo[]>>;
let execFileMock: AsyncFnMock<
ReturnType<typeof execFileInjectable["instantiate"]>
>;
beforeEach(async () => {
applicationBuilder = getApplicationBuilder();
execFileMock = asyncFn();
getActiveHelmRepositoriesMock = asyncFn();
applicationBuilder.beforeApplicationStart(({ mainDi, rendererDi }) => {
rendererDi.override(callForPublicHelmRepositoriesInjectable, () => async () => []);
mainDi.override(
getActiveHelmRepositoriesInjectable,
() => getActiveHelmRepositoriesMock,
);
mainDi.override(execFileInjectable, () => execFileMock);
mainDi.override(helmBinaryPathInjectable, () => "some-helm-binary-path");
});
rendered = await applicationBuilder.render();
});
describe("when navigating to preferences containing helm repositories", () => {
beforeEach(async () => {
applicationBuilder.preferences.navigate();
applicationBuilder.preferences.navigation.click("kubernetes");
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
describe("when active repositories resolve", () => {
beforeEach(async () => {
getActiveHelmRepositoriesMock.resolve({
callWasSuccessful: true,
response: [
{ name: "some-active-repository", url: "some-url" },
],
});
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
describe("when removing repository", () => {
beforeEach(() => {
execFileMock.mockClear();
getActiveHelmRepositoriesMock.mockClear();
const removeButton = rendered.getByTestId(
"remove-helm-repository-some-active-repository",
);
fireEvent.click(removeButton);
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("removes the repository", () => {
expect(execFileMock).toHaveBeenCalledWith(
"some-helm-binary-path",
["repo", "remove", "some-active-repository"],
);
});
it("does not reload active repositories yet", () => {
expect(getActiveHelmRepositoriesMock).not.toHaveBeenCalled();
});
describe("when removing resolves", () => {
beforeEach(async () => {
await execFileMock.resolveSpecific(
[
"some-helm-binary-path",
["repo", "remove", "some-active-repository"],
],
"",
);
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("reloads active repositories", () => {
expect(getActiveHelmRepositoriesMock).toHaveBeenCalled();
});
});
});
});
});
});

View File

@ -834,114 +834,121 @@ exports[`preferences - navigation to kubernetes preferences given in preferences
</h2>
<div>
<div
class="flex gaps"
data-testid="helm-controls"
>
<div
class="Select theme-lens box grow Select--is-disabled css-3iigni-container"
class="flex gaps"
>
<span
class="css-1f43avz-a11yText-A11yText"
id="react-select-HelmRepoSelect-live-region"
/>
<span
aria-atomic="false"
aria-live="polite"
aria-relevant="additions text"
class="css-1f43avz-a11yText-A11yText"
/>
<div
class="Select__control Select__control--is-disabled css-1insrsq-control"
class="Select theme-lens box grow Select--is-disabled css-3iigni-container"
>
<span
class="css-1f43avz-a11yText-A11yText"
id="react-select-selection-of-active-public-helm-repository-live-region"
/>
<span
aria-atomic="false"
aria-live="polite"
aria-relevant="additions text"
class="css-1f43avz-a11yText-A11yText"
/>
<div
class="Select__value-container css-319lph-ValueContainer"
class="Select__control Select__control--is-disabled css-1insrsq-control"
>
<div
class="Select__placeholder css-14el2xx-placeholder"
id="react-select-HelmRepoSelect-placeholder"
class="Select__value-container css-319lph-ValueContainer"
>
Repositories
</div>
<div
class="Select__input-container css-jzldcf-Input"
data-value=""
>
<input
aria-autocomplete="list"
aria-describedby="react-select-HelmRepoSelect-placeholder"
aria-expanded="false"
aria-haspopup="true"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
class="Select__input"
disabled=""
id="HelmRepoSelect"
role="combobox"
spellcheck="false"
style="opacity: 1; width: 100%; grid-area: 1 / 2; min-width: 2px; border: 0px; margin: 0px; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
</div>
</div>
<div
class="Select__indicators css-1hb7zxy-IndicatorsContainer"
>
<div
aria-hidden="true"
class="Select__indicator Select__loading-indicator css-at12u2-loadingIndicator"
>
<span
class="css-1xtdfmb-LoadingDot"
/>
<span
class="css-zoievk-LoadingDot"
/>
<span
class="css-x748d8-LoadingDot"
/>
</div>
<span
class="Select__indicator-separator css-109onse-indicatorSeparator"
/>
<div
aria-hidden="true"
class="Select__indicator Select__dropdown-indicator css-tlfecz-indicatorContainer"
>
<svg
aria-hidden="true"
class="css-tj5bde-Svg"
focusable="false"
height="20"
viewBox="0 0 20 20"
width="20"
<div
class="Select__placeholder css-14el2xx-placeholder"
id="react-select-selection-of-active-public-helm-repository-placeholder"
>
<path
d="M4.516 7.548c0.436-0.446 1.043-0.481 1.576 0l3.908 3.747 3.908-3.747c0.533-0.481 1.141-0.446 1.574 0 0.436 0.445 0.408 1.197 0 1.615-0.406 0.418-4.695 4.502-4.695 4.502-0.217 0.223-0.502 0.335-0.787 0.335s-0.57-0.112-0.789-0.335c0 0-4.287-4.084-4.695-4.502s-0.436-1.17 0-1.615z"
Repositories
</div>
<div
class="Select__input-container css-jzldcf-Input"
data-value=""
>
<input
aria-autocomplete="list"
aria-describedby="react-select-selection-of-active-public-helm-repository-placeholder"
aria-expanded="false"
aria-haspopup="true"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
class="Select__input"
disabled=""
id="selection-of-active-public-helm-repository"
role="combobox"
spellcheck="false"
style="opacity: 1; width: 100%; grid-area: 1 / 2; min-width: 2px; border: 0px; margin: 0px; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
</svg>
</div>
</div>
<div
class="Select__indicators css-1hb7zxy-IndicatorsContainer"
>
<div
aria-hidden="true"
class="Select__indicator Select__loading-indicator css-at12u2-loadingIndicator"
>
<span
class="css-1xtdfmb-LoadingDot"
/>
<span
class="css-zoievk-LoadingDot"
/>
<span
class="css-x748d8-LoadingDot"
/>
</div>
<span
class="Select__indicator-separator css-109onse-indicatorSeparator"
/>
<div
aria-hidden="true"
class="Select__indicator Select__dropdown-indicator css-tlfecz-indicatorContainer"
>
<svg
aria-hidden="true"
class="css-tj5bde-Svg"
focusable="false"
height="20"
viewBox="0 0 20 20"
width="20"
>
<path
d="M4.516 7.548c0.436-0.446 1.043-0.481 1.576 0l3.908 3.747 3.908-3.747c0.533-0.481 1.141-0.446 1.574 0 0.436 0.445 0.408 1.197 0 1.615-0.406 0.418-4.695 4.502-4.695 4.502-0.217 0.223-0.502 0.335-0.787 0.335s-0.57-0.112-0.789-0.335c0 0-4.287-4.084-4.695-4.502s-0.436-1.17 0-1.615z"
/>
</svg>
</div>
</div>
</div>
</div>
<button
class="Button primary"
data-testid="add-custom-helm-repo-button"
type="button"
>
Add Custom Helm Repo
</button>
</div>
<button
class="Button primary"
type="button"
>
Add Custom Helm Repo
</button>
</div>
<div
class="repos"
>
<div
class="pt-5 relative"
class="repos"
>
<div
class="Spinner singleColor center"
/>
class="pt-5 relative"
>
<div
class="Spinner singleColor center"
data-testid="helm-repositories-are-loading"
/>
</div>
</div>
<div />
</div>
</div>
</section>

View File

@ -5,6 +5,8 @@
import type { RenderResult } from "@testing-library/react";
import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder";
import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder";
import callForPublicHelmRepositoriesInjectable from "../../renderer/components/+preferences/kubernetes/helm-charts/adding-of-public-helm-repository/public-helm-repositories/call-for-public-helm-repositories.injectable";
import getActiveHelmRepositoriesInjectable from "../../main/helm/repositories/get-active-helm-repositories/get-active-helm-repositories.injectable";
describe("preferences - navigation to kubernetes preferences", () => {
let applicationBuilder: ApplicationBuilder;
@ -17,6 +19,15 @@ describe("preferences - navigation to kubernetes preferences", () => {
let rendered: RenderResult;
beforeEach(async () => {
applicationBuilder.beforeApplicationStart(({ rendererDi, mainDi }) => {
rendererDi.override(callForPublicHelmRepositoriesInjectable, () => async () => []);
mainDi.override(
getActiveHelmRepositoriesInjectable,
() => async () => ({ callWasSuccessful: true, response: [] }),
);
});
applicationBuilder.beforeRender(() => {
applicationBuilder.preferences.navigate();
});

View File

@ -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 { execFile } from "child_process";
import { promisify } from "util";
export type ExecFile = (filePath: string, args: string[]) => Promise<string>;
const execFileInjectable = getInjectable({
id: "exec-file",
instantiate: (): ExecFile => async (filePath, args) => {
const asyncExecFile = promisify(execFile);
const result = await asyncExecFile(filePath, args);
return result.stdout;
},
causesSideEffects: true,
});
export default execFileInjectable;

View File

@ -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 readFileInjectable from "./read-file.injectable";
import yaml from "js-yaml";
export type ReadYamlFile = (filePath: string) => Promise<unknown>;
const readYamlFileInjectable = getInjectable({
id: "read-yaml-file",
instantiate: (di): ReadYamlFile => {
const readFile = di.inject(readFileInjectable);
return async (filePath: string) => {
const contents = await readFile(filePath);
return yaml.load(contents);
};
},
});
export default readYamlFileInjectable;

View File

@ -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 type { HelmRepo } from "./helm-repo";
import type { RequestChannel } from "../utils/channel/request-channel-injection-token";
import { requestChannelInjectionToken } from "../utils/channel/request-channel-injection-token";
import type { AsyncResult } from "../utils/async-result";
export type AddHelmRepositoryChannel = RequestChannel<HelmRepo, AsyncResult<string>>;
const addHelmRepositoryChannelInjectable = getInjectable({
id: "add-helm-repository-channel",
instantiate: (): AddHelmRepositoryChannel => ({
id: "add-helm-repository-channel",
}),
injectionToken: requestChannelInjectionToken,
});
export default addHelmRepositoryChannelInjectable;

View File

@ -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 type { RequestChannel } from "../utils/channel/request-channel-injection-token";
import type { HelmRepo } from "./helm-repo";
import { requestChannelInjectionToken } from "../utils/channel/request-channel-injection-token";
import type { AsyncResult } from "../utils/async-result";
export type GetHelmRepositoriesChannel = RequestChannel<void, AsyncResult<HelmRepo[]>>;
const getActiveHelmRepositoriesChannelInjectable = getInjectable({
id: "get-active-helm-repositories-channel",
instantiate: (): GetHelmRepositoriesChannel => ({
id: "get-helm-active-list-repositories",
}),
injectionToken: requestChannelInjectionToken,
});
export default getActiveHelmRepositoriesChannelInjectable;

View File

@ -0,0 +1,16 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export type HelmRepo = {
name: string;
url: string;
cacheFilePath?: string;
caFile?: string;
certFile?: string;
insecureSkipTlsVerify?: boolean;
keyFile?: string;
username?: string;
password?: string;
};

View File

@ -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 { HelmRepo } from "./helm-repo";
import type { RequestChannel } from "../utils/channel/request-channel-injection-token";
import { requestChannelInjectionToken } from "../utils/channel/request-channel-injection-token";
export type RemoveHelmRepositoryChannel = RequestChannel<HelmRepo>;
const removeHelmRepositoryChannelInjectable = getInjectable({
id: "remove-helm-repository-channel",
instantiate: (): RemoveHelmRepositoryChannel => ({
id: "remove-helm-repository-channel",
}),
injectionToken: requestChannelInjectionToken,
});
export default removeHelmRepositoryChannelInjectable;

View File

@ -0,0 +1,7 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
export type AsyncResult<Response, Error = string> =
| { callWasSuccessful: true; response: Response }
| { callWasSuccessful: false; error: Error };

View File

@ -0,0 +1,15 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
export const getErrorMessage = (error: unknown): string => {
if (typeof error === "string") {
return error;
}
if (error instanceof Error) {
return error.message;
}
return JSON.stringify(error);
};

View File

@ -3,16 +3,22 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import path from "path";
import bundledBinariesNormalizedArchInjectable from "./bundled-binaries-normalized-arch.injectable";
import bundledResourcesDirectoryInjectable from "./bundled-resources-dir.injectable";
import getAbsolutePathInjectable from "../path/get-absolute-path.injectable";
import normalizedPlatformArchitectureInjectable from "./normalized-platform-architecture.injectable";
const baseBundeledBinariesDirectoryInjectable = getInjectable({
id: "base-bundeled-binaries-directory",
instantiate: (di) => path.join(
di.inject(bundledResourcesDirectoryInjectable),
di.inject(bundledBinariesNormalizedArchInjectable),
),
const baseBundledBinariesDirectoryInjectable = getInjectable({
id: "base-bundled-binaries-directory",
instantiate: (di) => {
const bundledResourcesDirectory = di.inject(bundledResourcesDirectoryInjectable);
const normalizedPlatformArchitecture = di.inject(normalizedPlatformArchitectureInjectable);
const getAbsolutePath = di.inject(getAbsolutePathInjectable);
return getAbsolutePath(
bundledResourcesDirectory,
normalizedPlatformArchitecture,
);
},
});
export default baseBundeledBinariesDirectoryInjectable;
export default baseBundledBinariesDirectoryInjectable;

View File

@ -3,19 +3,22 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import path from "path";
import isProductionInjectable from "./is-production.injectable";
import normalizedPlatformInjectable from "./normalized-platform.injectable";
import getAbsolutePathInjectable from "../path/get-absolute-path.injectable";
import lensResourcesDirInjectable from "./lens-resources-dir.injectable";
const bundledResourcesDirectoryInjectable = getInjectable({
id: "bundled-resources-directory",
instantiate: (di) => {
const isProduction = di.inject(isProductionInjectable);
const normalizedPlatform = di.inject(normalizedPlatformInjectable);
const getAbsolutePath = di.inject(getAbsolutePathInjectable);
const lensResourcesDir = di.inject(lensResourcesDirInjectable);
return isProduction
? process.resourcesPath
: path.join(process.cwd(), "binaries", "client", normalizedPlatform);
? lensResourcesDir
: getAbsolutePath(lensResourcesDir, "binaries", "client", normalizedPlatform);
},
});

View File

@ -4,8 +4,8 @@
*/
import { getInjectable } from "@ogre-tools/injectable";
const bundledBinariesNormalizedArchInjectable = getInjectable({
id: "bundled-binaries-normalized-arch",
const normalizedPlatformArchitectureInjectable = getInjectable({
id: "normalized-platform-architecture",
instantiate: () => {
switch (process.arch) {
case "arm64":
@ -24,4 +24,4 @@ const bundledBinariesNormalizedArchInjectable = getInjectable({
causesSideEffects: true,
});
export default bundledBinariesNormalizedArchInjectable;
export default normalizedPlatformArchitectureInjectable;

View File

@ -3,11 +3,15 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import platformInjectable from "./platform.injectable";
const normalizedPlatformInjectable = getInjectable({
id: "normalized-platform",
instantiate: () => {
switch (process.platform) {
instantiate: (di) => {
const platform = di.inject(platformInjectable);
switch (platform) {
case "darwin":
return "darwin";
case "linux":
@ -15,10 +19,9 @@ const normalizedPlatformInjectable = getInjectable({
case "win32":
return "windows";
default:
throw new Error(`platform=${process.platform} is unsupported`);
throw new Error(`platform=${platform} is unsupported`);
}
},
causesSideEffects: true,
});
export default normalizedPlatformInjectable;

View File

@ -5,7 +5,7 @@
import glob from "glob";
import { kebabCase, memoize, noop } from "lodash/fp";
import type { DiContainer } from "@ogre-tools/injectable";
import type { DiContainer, Injectable } from "@ogre-tools/injectable";
import { createContainer } from "@ogre-tools/injectable";
import { Environments, setLegacyGlobalDiForExtensionApi } from "../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api";
import appNameInjectable from "./app-paths/app-name/app-name.injectable";
@ -76,7 +76,7 @@ import quitAndInstallUpdateInjectable from "./electron-app/features/quit-and-ins
import electronUpdaterIsActiveInjectable from "./electron-app/features/electron-updater-is-active.injectable";
import publishIsConfiguredInjectable from "./application-update/publish-is-configured.injectable";
import checkForPlatformUpdatesInjectable from "./application-update/check-for-platform-updates/check-for-platform-updates.injectable";
import baseBundeledBinariesDirectoryInjectable from "../common/vars/base-bundled-binaries-dir.injectable";
import baseBundledBinariesDirectoryInjectable from "../common/vars/base-bundled-binaries-dir.injectable";
import setUpdateOnQuitInjectable from "./electron-app/features/set-update-on-quit.injectable";
import downloadPlatformUpdateInjectable from "./application-update/download-platform-update/download-platform-update.injectable";
import startCatalogSyncInjectable from "./catalog-sync-to-renderer/start-catalog-sync.injectable";
@ -84,6 +84,19 @@ import startKubeConfigSyncInjectable from "./start-main-application/runnables/ku
import appVersionInjectable from "../common/get-configuration-file-model/app-version/app-version.injectable";
import getRandomIdInjectable from "../common/utils/get-random-id.injectable";
import periodicalCheckForUpdatesInjectable from "./application-update/periodical-check-for-updates/periodical-check-for-updates.injectable";
import execFileInjectable from "../common/fs/exec-file.injectable";
import normalizedPlatformArchitectureInjectable from "../common/vars/normalized-platform-architecture.injectable";
import getHelmChartInjectable from "./helm/helm-service/get-helm-chart.injectable";
import getHelmChartValuesInjectable from "./helm/helm-service/get-helm-chart-values.injectable";
import listHelmChartsInjectable from "./helm/helm-service/list-helm-charts.injectable";
import deleteHelmReleaseInjectable from "./helm/helm-service/delete-helm-release.injectable";
import getHelmReleaseHistoryInjectable from "./helm/helm-service/get-helm-release-history.injectable";
import getHelmReleaseInjectable from "./helm/helm-service/get-helm-release.injectable";
import getHelmReleaseValuesInjectable from "./helm/helm-service/get-helm-release-values.injectable";
import installHelmChartInjectable from "./helm/helm-service/install-helm-chart.injectable";
import listHelmReleasesInjectable from "./helm/helm-service/list-helm-releases.injectable";
import rollbackHelmReleaseInjectable from "./helm/helm-service/rollback-helm-release.injectable";
import updateHelmReleaseInjectable from "./helm/helm-service/update-helm-release.injectable";
export function getDiForUnitTesting(opts: { doGeneralOverrides?: boolean } = {}) {
const {
@ -134,6 +147,24 @@ export function getDiForUnitTesting(opts: { doGeneralOverrides?: boolean } = {})
di.override(periodicalCheckForUpdatesInjectable, () => ({ start: () => {}, stop: () => {}, started: false }));
overrideFunctionalInjectables(di, [
getHelmChartInjectable,
getHelmChartValuesInjectable,
listHelmChartsInjectable,
deleteHelmReleaseInjectable,
getHelmReleaseHistoryInjectable,
getHelmReleaseInjectable,
getHelmReleaseValuesInjectable,
installHelmChartInjectable,
listHelmReleasesInjectable,
rollbackHelmReleaseInjectable,
updateHelmReleaseInjectable,
writeJsonFileInjectable,
readJsonFileInjectable,
readFileInjectable,
execFileInjectable,
]);
// TODO: Remove usages of globally exported appEventBus to get rid of this
di.override(appEventBusInjectable, () => new EventEmitter<[AppEvent]>());
@ -141,7 +172,7 @@ export function getDiForUnitTesting(opts: { doGeneralOverrides?: boolean } = {})
di.override(broadcastMessageInjectable, () => (channel) => {
throw new Error(`Tried to broadcast message to channel "${channel}" over IPC without explicit override.`);
});
di.override(baseBundeledBinariesDirectoryInjectable, () => "some-bin-directory");
di.override(baseBundledBinariesDirectoryInjectable, () => "some-bin-directory");
di.override(spawnInjectable, () => () => {
return {
stderr: { on: jest.fn(), removeAllListeners: jest.fn() },
@ -150,18 +181,6 @@ export function getDiForUnitTesting(opts: { doGeneralOverrides?: boolean } = {})
} as never;
});
di.override(writeJsonFileInjectable, () => () => {
throw new Error("Tried to write JSON file to file system without specifying explicit override.");
});
di.override(readJsonFileInjectable, () => () => {
throw new Error("Tried to read JSON file from file system without specifying explicit override.");
});
di.override(readFileInjectable, () => () => {
throw new Error("Tried to read file from file system without specifying explicit override.");
});
di.override(loggerInjectable, () => ({
warn: noop,
debug: noop,
@ -204,6 +223,7 @@ const overrideOperatingSystem = (di: DiContainer) => {
di.override(platformInjectable, () => "darwin");
di.override(getAbsolutePathInjectable, () => getAbsolutePathFake);
di.override(joinPathsInjectable, () => joinPathsFake);
di.override(normalizedPlatformArchitectureInjectable, () => "arm64");
};
const overrideElectronFeatures = (di: DiContainer) => {
@ -257,3 +277,11 @@ const overrideElectronFeatures = (di: DiContainer) => {
di.override(publishIsConfiguredInjectable, () => false);
di.override(electronUpdaterIsActiveInjectable, () => false);
};
const overrideFunctionalInjectables = (di: DiContainer, injectables: Injectable<any, any, any>[]) => {
injectables.forEach(injectable => {
di.override(injectable, () => () => {
throw new Error(`Tried to run "${injectable.id}" without explicit override.`);
});
});
};

View File

@ -4,7 +4,7 @@
*/
import { sortCharts } from "../../../common/utils";
import type { HelmRepo } from "../helm-repo-manager";
import type { HelmRepo } from "../../../common/helm/helm-repo";
const charts = new Map([
["stable", {

View File

@ -3,27 +3,48 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { helmService } from "../helm-service";
import { HelmRepoManager } from "../helm-repo-manager";
const mockHelmRepoManager = jest.spyOn(HelmRepoManager, "getInstance").mockImplementation();
import { getDiForUnitTesting } from "../../getDiForUnitTesting";
import listHelmChartsInjectable from "../helm-service/list-helm-charts.injectable";
import getActiveHelmRepositoriesInjectable from "../repositories/get-active-helm-repositories/get-active-helm-repositories.injectable";
import type { AsyncResult } from "../../../common/utils/async-result";
import type { HelmRepo } from "../../../common/helm/helm-repo";
jest.mock("../helm-chart-manager");
describe("Helm Service tests", () => {
let listHelmCharts: () => Promise<any>;
let getActiveHelmRepositoriesMock: jest.Mock<Promise<AsyncResult<HelmRepo[]>>>;
beforeEach(() => {
const di = getDiForUnitTesting({ doGeneralOverrides: true });
getActiveHelmRepositoriesMock = jest.fn();
di.override(getActiveHelmRepositoriesInjectable, () => getActiveHelmRepositoriesMock);
di.unoverride(listHelmChartsInjectable);
di.permitSideEffects(listHelmChartsInjectable);
listHelmCharts = di.inject(listHelmChartsInjectable);
});
afterEach(() => {
jest.resetAllMocks();
});
it("list charts with deprecated entries", async () => {
mockHelmRepoManager.mockReturnValue({
repositories: jest.fn().mockImplementation(async () => [
{ name: "stable", url: "stableurl" },
{ name: "experiment", url: "experimenturl" },
]),
} as any);
getActiveHelmRepositoriesMock.mockReturnValue(
Promise.resolve({
callWasSuccessful: true,
const charts = await helmService.listCharts();
response: [
{ name: "stable", url: "stableurl" },
{ name: "experiment", url: "experimenturl" },
],
}),
);
const charts = await listHelmCharts();
expect(charts).toEqual({
stable: {
@ -123,15 +144,14 @@ describe("Helm Service tests", () => {
});
it("list charts sorted by version in descending order", async () => {
mockHelmRepoManager.mockReturnValue({
repositories: jest.fn().mockImplementation(async () => {
return [
{ name: "bitnami", url: "bitnamiurl" },
];
getActiveHelmRepositoriesMock.mockReturnValue(
Promise.resolve({
callWasSuccessful: true,
response: [{ name: "bitnami", url: "bitnamiurl" }],
}),
} as any);
);
const charts = await helmService.listCharts();
const charts = await listHelmCharts();
expect(charts).toEqual({
bitnami: {

View File

@ -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 execFileInjectable from "../../../common/fs/exec-file.injectable";
import helmBinaryPathInjectable from "../helm-binary-path.injectable";
import type { AsyncResult } from "../../../common/utils/async-result";
import { getErrorMessage } from "../../../common/utils/get-error-message";
const execHelmInjectable = getInjectable({
id: "exec-helm",
instantiate: (di) => {
const execFile = di.inject(execFileInjectable);
const helmBinaryPath = di.inject(helmBinaryPathInjectable);
return async (...args: string[]): Promise<AsyncResult<string>> => {
try {
const response = await execFile(helmBinaryPath, args);
return { callWasSuccessful: true, response };
} catch (error) {
return { callWasSuccessful: false, error: getErrorMessage(error) };
}
};
},
});
export default execHelmInjectable;

View File

@ -0,0 +1,43 @@
/**
* 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 execHelmInjectable from "../exec-helm/exec-helm.injectable";
import type { AsyncResult } from "../../../common/utils/async-result";
export type HelmEnv = Record<string, string> & {
HELM_REPOSITORY_CACHE?: string;
HELM_REPOSITORY_CONFIG?: string;
};
const getHelmEnvInjectable = getInjectable({
id: "get-helm-env",
instantiate: (di) => {
const execHelm = di.inject(execHelmInjectable);
return async (): Promise<AsyncResult<HelmEnv>> => {
const result = await execHelm("env");
if (!result.callWasSuccessful) {
return { callWasSuccessful: false, error: result.error };
}
const lines = result.response.split(/\r?\n/); // split by new line feed
const env: HelmEnv = {};
lines.forEach((line: string) => {
const [key, value] = line.split("=");
if (key && value) {
env[key] = value.replace(/"/g, ""); // strip quotas
}
});
return { callWasSuccessful: true, response: env };
};
},
});
export default getHelmEnvInjectable;

View File

@ -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 { getBinaryName } from "../../common/vars";
import getAbsolutePathInjectable from "../../common/path/get-absolute-path.injectable";
import normalizedPlatformInjectable from "../../common/vars/normalized-platform.injectable";
import baseBundledBinariesDirectoryInjectable from "../../common/vars/base-bundled-binaries-dir.injectable";
const helmBinaryPathInjectable = getInjectable({
id: "helm-binary-path",
instantiate: (di) => {
const getAbsolutePath = di.inject(getAbsolutePathInjectable);
const normalizedPlatform = di.inject(normalizedPlatformInjectable);
const baseBundledBinariesDirectory = di.inject(baseBundledBinariesDirectoryInjectable);
const helmBinaryName = getBinaryName("helm", { forPlatform: normalizedPlatform });
return getAbsolutePath(baseBundledBinariesDirectory, helmBinaryName);
},
});
export default helmBinaryPathInjectable;

View File

@ -5,13 +5,13 @@
import fs from "fs";
import * as yaml from "js-yaml";
import type { HelmRepo } from "./helm-repo-manager";
import logger from "../logger";
import type { RepoHelmChartList } from "../../common/k8s-api/endpoints/helm-charts.api";
import { iter, put, sortCharts } from "../../common/utils";
import { execHelm } from "./exec";
import type { SetRequired } from "type-fest";
import { assert } from "console";
import type { HelmRepo } from "../../common/helm/helm-repo";
interface ChartCacheEntry {
data: string; // serialized JSON

View File

@ -1,172 +0,0 @@
/**
* 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 yaml from "js-yaml";
import { readFile } from "fs-extra";
import { customRequestPromise } from "../../common/request";
import orderBy from "lodash/orderBy";
import logger from "../logger";
import { execHelm } from "./exec";
import type { HelmEnv, HelmRepo, HelmRepoConfig } from "./helm-repo-manager";
interface EnsuredHelmRepoManagerData {
helmEnv: HelmEnv;
didUpdateOnce: boolean;
}
export class HelmRepoManager {
protected helmEnv?: HelmEnv;
protected didUpdateOnce?: boolean;
public async loadAvailableRepos(): Promise<HelmRepo[]> {
const res = await customRequestPromise({
uri: "https://github.com/lensapp/artifact-hub-repositories/releases/download/latest/repositories.json",
json: true,
resolveWithFullResponse: true,
timeout: 10000,
});
return orderBy(res.body as HelmRepo[], repo => repo.name);
}
private async ensureInitialized(): Promise<EnsuredHelmRepoManagerData> {
this.helmEnv ??= await this.parseHelmEnv();
const repos = await this.list(this.helmEnv);
if (repos.length === 0) {
await this.addRepo({
name: "bitnami",
url: "https://charts.bitnami.com/bitnami",
});
}
if (!this.didUpdateOnce) {
await this.update();
this.didUpdateOnce = true;
}
return {
didUpdateOnce: this.didUpdateOnce,
helmEnv: this.helmEnv,
};
}
protected async parseHelmEnv() {
const output = await execHelm(["env"]);
const lines = output.split(/\r?\n/); // split by new line feed
const env: Partial<Record<string, string>> = {};
lines.forEach((line: string) => {
const [key, value] = line.split("=");
if (key && value) {
env[key] = value.replace(/"/g, ""); // strip quotas
}
});
return env as HelmEnv;
}
public async repo(name: string): Promise<HelmRepo | undefined> {
const repos = await this.repositories();
return repos.find(repo => repo.name === name);
}
private async list(helmEnv: HelmEnv): Promise<HelmRepo[]> {
try {
const rawConfig = await readFile(helmEnv.HELM_REPOSITORY_CONFIG, "utf8");
const parsedConfig = yaml.load(rawConfig) as HelmRepoConfig;
if (typeof parsedConfig === "object" && parsedConfig) {
return parsedConfig.repositories;
}
} catch {
// ignore error
}
return [];
}
public async repositories(): Promise<HelmRepo[]> {
try {
const { helmEnv } = await this.ensureInitialized();
const repos = await this.list(helmEnv);
return repos.map(repo => ({
...repo,
cacheFilePath: `${helmEnv.HELM_REPOSITORY_CACHE}/${repo.name}-index.yaml`,
}));
} catch (error) {
logger.error(`[HELM]: repositories listing error`, error);
return [];
}
}
public async update() {
return execHelm([
"repo",
"update",
]);
}
public async addRepo({ name, url, insecureSkipTlsVerify, username, password, caFile, keyFile, certFile }: HelmRepo) {
logger.info(`[HELM]: adding repo ${name} from ${url}`);
const args = [
"repo",
"add",
name,
url,
];
if (insecureSkipTlsVerify) {
args.push("--insecure-skip-tls-verify");
}
if (username) {
args.push("--username", username);
}
if (password) {
args.push("--password", password);
}
if (caFile) {
args.push("--ca-file", caFile);
}
if (keyFile) {
args.push("--key-file", keyFile);
}
if (certFile) {
args.push("--cert-file", certFile);
}
return execHelm(args);
}
public async removeRepo({ name, url }: HelmRepo): Promise<string> {
logger.info(`[HELM]: removing repo ${name} (${url})`);
return execHelm([
"repo",
"remove",
name,
]);
}
}
const helmRepoManagerInjectable = getInjectable({
id: "helm-repo-manager",
instantiate: () => new HelmRepoManager(),
});
export default helmRepoManagerInjectable;

View File

@ -1,33 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import {
asLegacyGlobalSingletonForExtensionApi,
} from "../../extensions/as-legacy-globals-for-extension-api/as-legacy-global-singleton-object-for-extension-api";
import helmRepoManagerInjectable from "./helm-repo-manager.injectable";
export type HelmEnv = Partial<Record<string, string>> & {
HELM_REPOSITORY_CACHE: string;
HELM_REPOSITORY_CONFIG: string;
};
export interface HelmRepoConfig {
repositories: HelmRepo[];
}
export interface HelmRepo {
name: string;
url: string;
cacheFilePath?: string;
caFile?: string;
certFile?: string;
insecureSkipTlsVerify?: boolean;
keyFile?: string;
username?: string;
password?: string;
}
export const HelmRepoManager = asLegacyGlobalSingletonForExtensionApi(helmRepoManagerInjectable);

View File

@ -1,134 +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 logger from "../logger";
import { HelmRepoManager } from "./helm-repo-manager";
import { HelmChartManager } from "./helm-chart-manager";
import { deleteRelease, getHistory, getRelease, getValues, installChart, listReleases, rollback, upgradeRelease } from "./helm-release-manager";
import type { JsonObject } from "type-fest";
import { object } from "../../common/utils";
interface GetReleaseValuesArgs {
cluster: Cluster;
namespace: string;
all: boolean;
}
export interface InstallChartArgs {
chart: string;
values: JsonObject;
name: string;
namespace: string;
version: string;
}
export interface UpdateChartArgs {
chart: string;
values: JsonObject;
version: string;
}
class HelmService {
public async installChart(cluster: Cluster, data: InstallChartArgs) {
const proxyKubeconfig = await cluster.getProxyKubeconfigPath();
return installChart(data.chart, data.values, data.name, data.namespace, data.version, proxyKubeconfig);
}
public async listCharts() {
const repositories = await HelmRepoManager.getInstance().repositories();
return object.fromEntries(
await Promise.all(repositories.map(async repo => [repo.name, await HelmChartManager.forRepo(repo).charts()] as const)),
);
}
public async getChart(repoName: string, chartName: string, version = "") {
const repo = await HelmRepoManager.getInstance().repo(repoName);
if (!repo) {
return undefined;
}
const chartManager = HelmChartManager.forRepo(repo);
return {
readme: await chartManager.getReadme(chartName, version),
versions: await chartManager.chartVersions(chartName),
};
}
public async getChartValues(repoName: string, chartName: string, version = "") {
const repo = await HelmRepoManager.getInstance().repo(repoName);
if (!repo) {
return undefined;
}
return HelmChartManager.forRepo(repo).getValues(chartName, version);
}
public async listReleases(cluster: Cluster, namespace?: string) {
const proxyKubeconfig = await cluster.getProxyKubeconfigPath();
logger.debug("list releases");
return listReleases(proxyKubeconfig, namespace);
}
public async getRelease(cluster: Cluster, releaseName: string, namespace: string) {
const kubeconfigPath = await cluster.getProxyKubeconfigPath();
const kubectl = await cluster.ensureKubectl();
const kubectlPath = await kubectl.getPath();
logger.debug("Fetch release");
return getRelease(releaseName, namespace, kubeconfigPath, kubectlPath);
}
public async getReleaseValues(releaseName: string, { cluster, namespace, all }: GetReleaseValuesArgs) {
const pathToKubeconfig = await cluster.getProxyKubeconfigPath();
logger.debug("Fetch release values");
return getValues(releaseName, { namespace, all, kubeconfigPath: pathToKubeconfig });
}
public async getReleaseHistory(cluster: Cluster, releaseName: string, namespace: string) {
const proxyKubeconfig = await cluster.getProxyKubeconfigPath();
logger.debug("Fetch release history");
return getHistory(releaseName, namespace, proxyKubeconfig);
}
public async deleteRelease(cluster: Cluster, releaseName: string, namespace: string) {
const proxyKubeconfig = await cluster.getProxyKubeconfigPath();
logger.debug("Delete release");
return deleteRelease(releaseName, namespace, proxyKubeconfig);
}
public async updateRelease(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(releaseName, data.chart, data.values, namespace, data.version, proxyKubeconfig, kubectlPath);
}
public async rollback(cluster: Cluster, releaseName: string, namespace: string, revision: number) {
const proxyKubeconfig = await cluster.getProxyKubeconfigPath();
logger.debug("Rollback release");
await rollback(releaseName, namespace, revision, proxyKubeconfig);
}
}
export const helmService = new HelmService();

View File

@ -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 type { Cluster } from "../../../common/cluster/cluster";
import { deleteRelease } from "../helm-release-manager";
import loggerInjectable from "../../../common/logger.injectable";
const deleteHelmReleaseInjectable = getInjectable({
id: "delete-helm-release",
instantiate: (di) => {
const logger = di.inject(loggerInjectable);
return async (cluster: Cluster, releaseName: string, namespace: string) => {
const proxyKubeconfig = await cluster.getProxyKubeconfigPath();
logger.debug("Delete release");
return deleteRelease(releaseName, namespace, proxyKubeconfig);
};
},
causesSideEffects: true,
});
export default deleteHelmReleaseInjectable;

View File

@ -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 { HelmChartManager } from "../helm-chart-manager";
import getActiveHelmRepositoryInjectable from "../repositories/get-active-helm-repository.injectable";
const getHelmChartValuesInjectable = getInjectable({
id: "get-helm-chart-values",
instantiate: (di) => {
const getActiveHelmRepository = di.inject(getActiveHelmRepositoryInjectable);
return async (repoName: string, chartName: string, version = "") => {
const repo = await getActiveHelmRepository(repoName);
if (!repo) {
return undefined;
}
return HelmChartManager.forRepo(repo).getValues(chartName, version);
};
},
causesSideEffects: true,
});
export default getHelmChartValuesInjectable;

View File

@ -0,0 +1,34 @@
/**
* 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 { HelmChartManager } from "../helm-chart-manager";
import getActiveHelmRepositoryInjectable from "../repositories/get-active-helm-repository.injectable";
const getHelmChartInjectable = getInjectable({
id: "get-helm-chart",
instantiate: (di) => {
const getActiveHelmRepository = di.inject(getActiveHelmRepositoryInjectable);
return async (repoName: string, chartName: string, version = "") => {
const repo = await getActiveHelmRepository(repoName);
if (!repo) {
return undefined;
}
const chartManager = HelmChartManager.forRepo(repo);
return {
readme: await chartManager.getReadme(chartName, version),
versions: await chartManager.chartVersions(chartName),
};
};
},
causesSideEffects: true,
});
export default getHelmChartInjectable;

View File

@ -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 type { Cluster } from "../../../common/cluster/cluster";
import { getHistory } from "../helm-release-manager";
import loggerInjectable from "../../../common/logger.injectable";
const getHelmReleaseHistoryInjectable = getInjectable({
id: "get-helm-release-history",
instantiate: (di) => {
const logger = di.inject(loggerInjectable);
return async (cluster: Cluster, releaseName: string, namespace: string) => {
const proxyKubeconfig = await cluster.getProxyKubeconfigPath();
logger.debug("Fetch release history");
return getHistory(releaseName, namespace, proxyKubeconfig);
};
},
causesSideEffects: true,
});
export default getHelmReleaseHistoryInjectable;

View File

@ -0,0 +1,41 @@
/**
* 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 { getValues } from "../helm-release-manager";
import loggerInjectable from "../../../common/logger.injectable";
import type { Cluster } from "../../../common/cluster/cluster";
interface GetReleaseValuesArgs {
cluster: Cluster;
namespace: string;
all: boolean;
}
const getHelmReleaseValuesInjectable = getInjectable({
id: "get-helm-release-values",
instantiate: (di) => {
const logger = di.inject(loggerInjectable);
return async (
releaseName: string,
{ cluster, namespace, all }: GetReleaseValuesArgs,
) => {
const pathToKubeconfig = await cluster.getProxyKubeconfigPath();
logger.debug("Fetch release values");
return getValues(releaseName, {
namespace,
all,
kubeconfigPath: pathToKubeconfig,
});
};
},
causesSideEffects: true,
});
export default getHelmReleaseValuesInjectable;

View File

@ -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 type { Cluster } from "../../../common/cluster/cluster";
import { getRelease } from "../helm-release-manager";
import loggerInjectable from "../../../common/logger.injectable";
const getHelmReleaseInjectable = getInjectable({
id: "get-helm-release",
instantiate: (di) => {
const logger = di.inject(loggerInjectable);
return async (cluster: Cluster, releaseName: string, namespace: string) => {
const kubeconfigPath = await cluster.getProxyKubeconfigPath();
const kubectl = await cluster.ensureKubectl();
const kubectlPath = await kubectl.getPath();
logger.debug("Fetch release");
return getRelease(releaseName, namespace, kubeconfigPath, kubectlPath);
};
},
causesSideEffects: true,
});
export default getHelmReleaseInjectable;

View File

@ -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 type { JsonObject } from "type-fest";
import type { Cluster } from "../../../common/cluster/cluster";
import { installChart } from "../helm-release-manager";
export interface InstallChartArgs {
chart: string;
values: JsonObject;
name: string;
namespace: string;
version: string;
}
const installHelmChartInjectable = getInjectable({
id: "install-helm-chart",
instantiate: () => async (cluster: Cluster, data: InstallChartArgs) => {
const proxyKubeconfig = await cluster.getProxyKubeconfigPath();
return installChart(data.chart, data.values, data.name, data.namespace, data.version, proxyKubeconfig);
},
causesSideEffects: true,
});
export default installHelmChartInjectable;

View File

@ -0,0 +1,41 @@
/**
* 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 assert from "assert";
import { object } from "../../../common/utils";
import { HelmChartManager } from "../helm-chart-manager";
import getActiveHelmRepositoriesInjectable from "../repositories/get-active-helm-repositories/get-active-helm-repositories.injectable";
const listHelmChartsInjectable = getInjectable({
id: "list-helm-charts",
instantiate: (di) => {
const getActiveHelmRepositories = di.inject(getActiveHelmRepositoriesInjectable);
return async () => {
const result = await getActiveHelmRepositories();
assert(result.callWasSuccessful);
const repositories = result.response;
return object.fromEntries(
await Promise.all(
repositories.map(
async (repo) =>
[
repo.name,
await HelmChartManager.forRepo(repo).charts(),
] as const,
),
),
);
};
},
causesSideEffects: true,
});
export default listHelmChartsInjectable;

View File

@ -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 type { Cluster } from "../../../common/cluster/cluster";
import loggerInjectable from "../../../common/logger.injectable";
import { listReleases } from "../helm-release-manager";
const listHelmReleasesInjectable = getInjectable({
id: "list-helm-releases",
instantiate: (di) => {
const logger = di.inject(loggerInjectable);
return async (cluster: Cluster, namespace?: string) => {
const proxyKubeconfig = await cluster.getProxyKubeconfigPath();
logger.debug("list releases");
return listReleases(proxyKubeconfig, namespace);
};
},
causesSideEffects: true,
});
export default listHelmReleasesInjectable;

View File

@ -0,0 +1,32 @@
/**
* 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 loggerInjectable from "../../../common/logger.injectable";
import { rollback } from "../helm-release-manager";
const rollbackHelmReleaseInjectable = getInjectable({
id: "rollback-helm-release",
instantiate: (di) => {
const logger = di.inject(loggerInjectable);
return async (
cluster: Cluster,
releaseName: string,
namespace: string,
revision: number,
) => {
const proxyKubeconfig = await cluster.getProxyKubeconfigPath();
logger.debug("Rollback release");
await rollback(releaseName, namespace, revision, proxyKubeconfig);
};
},
causesSideEffects: true,
});
export default rollbackHelmReleaseInjectable;

View File

@ -0,0 +1,45 @@
/**
* 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 { upgradeRelease } from "../helm-release-manager";
import loggerInjectable from "../../../common/logger.injectable";
import type { JsonObject } from "type-fest";
export interface UpdateChartArgs {
chart: string;
values: JsonObject;
version: string;
}
const updateHelmReleaseInjectable = getInjectable({
id: "update-helm-release",
instantiate: (di) => {
const logger = di.inject(loggerInjectable);
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(
releaseName,
data.chart,
data.values,
namespace,
data.version,
proxyKubeconfig,
kubectlPath,
);
};
},
causesSideEffects: true,
});
export default updateHelmReleaseInjectable;

View File

@ -0,0 +1,26 @@
/**
* 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 addHelmRepositoryChannelInjectable from "../../../../common/helm/add-helm-repository-channel.injectable";
import addHelmRepositoryInjectable from "./add-helm-repository.injectable";
import { requestChannelListenerInjectionToken } from "../../../../common/utils/channel/request-channel-listener-injection-token";
const addHelmRepositoryChannelListenerInjectable = getInjectable({
id: "add-helm-repository-channel-listener",
instantiate: (di) => {
const addHelmRepository = di.inject(addHelmRepositoryInjectable);
const channel = di.inject(addHelmRepositoryChannelInjectable);
return {
channel,
handler: addHelmRepository,
};
},
injectionToken: requestChannelListenerInjectionToken,
});
export default addHelmRepositoryChannelListenerInjectable;

View File

@ -0,0 +1,62 @@
/**
* 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 execHelmInjectable from "../../exec-helm/exec-helm.injectable";
import type { HelmRepo } from "../../../../common/helm/helm-repo";
import loggerInjectable from "../../../../common/logger.injectable";
const addHelmRepositoryInjectable = getInjectable({
id: "add-helm-repository",
instantiate: (di) => {
const execHelm = di.inject(execHelmInjectable);
const logger = di.inject(loggerInjectable);
return async (repo: HelmRepo) => {
const {
name,
url,
insecureSkipTlsVerify,
username,
password,
caFile,
keyFile,
certFile,
} = repo;
logger.info(`[HELM]: adding repo ${name} from ${url}`);
const args = ["repo", "add", name, url];
if (insecureSkipTlsVerify) {
args.push("--insecure-skip-tls-verify");
}
if (username) {
args.push("--username", username);
}
if (password) {
args.push("--password", password);
}
if (caFile) {
args.push("--ca-file", caFile);
}
if (keyFile) {
args.push("--key-file", keyFile);
}
if (certFile) {
args.push("--cert-file", certFile);
}
return await execHelm(...args);
};
},
});
export default addHelmRepositoryInjectable;

View File

@ -0,0 +1,26 @@
/**
* 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 { requestChannelListenerInjectionToken } from "../../../../common/utils/channel/request-channel-listener-injection-token";
import getActiveHelmRepositoriesChannelInjectable from "../../../../common/helm/get-active-helm-repositories-channel.injectable";
import getActiveHelmRepositoriesInjectable from "./get-active-helm-repositories.injectable";
const getActiveHelmRepositoriesChannelListenerInjectable = getInjectable({
id: "get-active-helm-repositories-channel-listener",
instantiate: (di) => {
const getActiveHelmRepositories = di.inject(getActiveHelmRepositoriesInjectable);
return {
channel: di.inject(getActiveHelmRepositoriesChannelInjectable),
handler: getActiveHelmRepositories,
};
},
injectionToken: requestChannelListenerInjectionToken,
});
export default getActiveHelmRepositoriesChannelListenerInjectable;

View File

@ -0,0 +1,131 @@
/**
* 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 { HelmRepo } from "../../../../common/helm/helm-repo";
import type { ReadYamlFile } from "../../../../common/fs/read-yaml-file.injectable";
import readYamlFileInjectable from "../../../../common/fs/read-yaml-file.injectable";
import getHelmEnvInjectable from "../../get-helm-env/get-helm-env.injectable";
import execHelmInjectable from "../../exec-helm/exec-helm.injectable";
import loggerInjectable from "../../../../common/logger.injectable";
import type { AsyncResult } from "../../../../common/utils/async-result";
interface HelmRepositoryFromYaml {
name: string;
url: string;
caFile: string;
certFile: string;
insecure_skip_tls_verify: boolean;
keyFile: string;
pass_credentials_all: boolean;
password: string;
username: string;
}
export interface HelmRepositoriesFromYaml {
repositories: HelmRepositoryFromYaml[];
}
const getActiveHelmRepositoriesInjectable = getInjectable({
id: "get-helm-repositories",
instantiate: (di) => {
const readYamlFile = di.inject(readYamlFileInjectable);
const execHelm = di.inject(execHelmInjectable);
const getHelmEnv = di.inject(getHelmEnvInjectable);
const logger = di.inject(loggerInjectable);
const getRepositories = getRepositoriesFor(readYamlFile);
return async (): Promise<AsyncResult<HelmRepo[]>> => {
const envResult = await getHelmEnv();
if (!envResult.callWasSuccessful) {
return {
callWasSuccessful: false,
error: `Error getting Helm configuration: ${envResult.error}`,
};
}
const {
HELM_REPOSITORY_CONFIG: repositoryConfigFilePath,
HELM_REPOSITORY_CACHE: helmRepositoryCacheDirPath,
} = envResult.response;
if (!repositoryConfigFilePath) {
const errorMessage = "Tried to get Helm repositories, but HELM_REPOSITORY_CONFIG was not present in `$ helm env`.";
logger.error(errorMessage);
return {
callWasSuccessful: false,
error: `Error getting Helm configuration: ${errorMessage}`,
};
}
if (!helmRepositoryCacheDirPath) {
const errorMessage = "Tried to get Helm repositories, but HELM_REPOSITORY_CACHE was not present in `$ helm env`.";
logger.error(errorMessage);
return {
callWasSuccessful: false,
error: `Error getting Helm configuration: ${errorMessage}`,
};
}
const updateResult = await execHelm("repo", "update");
if (!updateResult.callWasSuccessful) {
if (!updateResult.error.includes(internalHelmErrorForNoRepositoriesFound)) {
return {
callWasSuccessful: false,
error: `Error updating Helm repositories: ${updateResult.error}`,
};
}
const resultOfAddingDefaultRepository = await execHelm("repo", "add", "bitnami", "https://charts.bitnami.com/bitnami");
if (!resultOfAddingDefaultRepository.callWasSuccessful) {
return {
callWasSuccessful: false,
error: `Error when adding default Helm repository: ${resultOfAddingDefaultRepository.error}`,
};
}
}
return {
callWasSuccessful: true,
response: await getRepositories(
repositoryConfigFilePath,
helmRepositoryCacheDirPath,
),
};
};
},
});
export default getActiveHelmRepositoriesInjectable;
const getRepositoriesFor =
(readYamlFile: ReadYamlFile) =>
async (repositoryConfigFilePath: string, helmRepositoryCacheDirPath: string): Promise<HelmRepo[]> => {
const { repositories } = (await readYamlFile(
repositoryConfigFilePath,
)) as HelmRepositoriesFromYaml;
return repositories.map((repository) => ({
name: repository.name,
url: repository.url,
caFile: repository.caFile,
certFile: repository.certFile,
insecureSkipTlsVerify: repository.insecure_skip_tls_verify,
keyFile: repository.keyFile,
username: repository.username,
password: repository.password,
cacheFilePath: `${helmRepositoryCacheDirPath}/${repository.name}-index.yaml`,
}));
};
const internalHelmErrorForNoRepositoriesFound = "no repositories found. You must add one before updating";

View File

@ -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 assert from "assert";
import getActiveHelmRepositoriesInjectable from "./get-active-helm-repositories/get-active-helm-repositories.injectable";
const getActiveHelmRepositoryInjectable = getInjectable({
id: "get-active-helm-repository",
instantiate: (di) => {
const getActiveHelmRepositories = di.inject(getActiveHelmRepositoriesInjectable);
return async (name: string) => {
const activeHelmRepositories = await getActiveHelmRepositories();
assert(activeHelmRepositories.callWasSuccessful);
return activeHelmRepositories.response.find(
(repository) => repository.name === name,
);
};
},
});
export default getActiveHelmRepositoryInjectable;

View File

@ -0,0 +1,26 @@
/**
* 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 { requestChannelListenerInjectionToken } from "../../../../common/utils/channel/request-channel-listener-injection-token";
import removeHelmRepositoryInjectable from "./remove-helm-repository.injectable";
import removeHelmRepositoryChannelInjectable from "../../../../common/helm/remove-helm-repository-channel.injectable";
const removeHelmRepositoryChannelListenerInjectable = getInjectable({
id: "remove-helm-repository-channel-listener",
instantiate: (di) => {
const removeHelmRepository = di.inject(removeHelmRepositoryInjectable);
const channel = di.inject(removeHelmRepositoryChannelInjectable);
return {
channel,
handler: removeHelmRepository,
};
},
injectionToken: requestChannelListenerInjectionToken,
});
export default removeHelmRepositoryChannelListenerInjectable;

View File

@ -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 execHelmInjectable from "../../exec-helm/exec-helm.injectable";
import type { HelmRepo } from "../../../../common/helm/helm-repo";
import loggerInjectable from "../../../../common/logger.injectable";
const removeHelmRepositoryInjectable = getInjectable({
id: "remove-helm-repository",
instantiate: (di) => {
const execHelm = di.inject(execHelmInjectable);
const logger = di.inject(loggerInjectable);
return async (repo: HelmRepo) => {
logger.info(`[HELM]: removing repo ${repo.name} (${repo.url})`);
return execHelm(
"repo",
"remove",
repo.name,
);
};
},
});
export default removeHelmRepositoryInjectable;

View File

@ -12,7 +12,7 @@ import { getBinaryName } from "../../common/vars";
import spawnInjectable from "../child-process/spawn.injectable";
import { getKubeAuthProxyCertificate } from "./get-kube-auth-proxy-certificate";
import loggerInjectable from "../../common/logger.injectable";
import baseBundeledBinariesDirectoryInjectable from "../../common/vars/base-bundled-binaries-dir.injectable";
import baseBundledBinariesDirectoryInjectable from "../../common/vars/base-bundled-binaries-dir.injectable";
export type CreateKubeAuthProxy = (cluster: Cluster, environmentVariables: NodeJS.ProcessEnv) => KubeAuthProxy;
@ -25,7 +25,7 @@ const createKubeAuthProxyInjectable = getInjectable({
return (cluster: Cluster, environmentVariables: NodeJS.ProcessEnv) => {
const clusterUrl = new URL(cluster.apiUrl);
const dependencies: KubeAuthProxyDependencies = {
proxyBinPath: path.join(di.inject(baseBundeledBinariesDirectoryInjectable), binaryName),
proxyBinPath: path.join(di.inject(baseBundledBinariesDirectoryInjectable), binaryName),
proxyCert: getKubeAuthProxyCertificate(clusterUrl.hostname, selfsigned.generate),
spawn: di.inject(spawnInjectable),
logger: di.inject(loggerInjectable),

View File

@ -4,13 +4,13 @@
*/
import { getInjectable } from "@ogre-tools/injectable";
import path from "path";
import baseBundeledBinariesDirectoryInjectable from "../../common/vars/base-bundled-binaries-dir.injectable";
import baseBundledBinariesDirectoryInjectable from "../../common/vars/base-bundled-binaries-dir.injectable";
import kubectlBinaryNameInjectable from "./binary-name.injectable";
const bundledKubectlBinaryPathInjectable = getInjectable({
id: "bundled-kubectl-binary-path",
instantiate: (di) => path.join(
di.inject(baseBundeledBinariesDirectoryInjectable),
di.inject(baseBundledBinariesDirectoryInjectable),
di.inject(kubectlBinaryNameInjectable),
),
});

View File

@ -11,7 +11,7 @@ import kubectlDownloadingNormalizedArchInjectable from "./normalized-arch.inject
import normalizedPlatformInjectable from "../../common/vars/normalized-platform.injectable";
import kubectlBinaryNameInjectable from "./binary-name.injectable";
import bundledKubectlBinaryPathInjectable from "./bundled-binary-path.injectable";
import baseBundeledBinariesDirectoryInjectable from "../../common/vars/base-bundled-binaries-dir.injectable";
import baseBundledBinariesDirectoryInjectable from "../../common/vars/base-bundled-binaries-dir.injectable";
const createKubectlInjectable = getInjectable({
id: "create-kubectl",
@ -24,7 +24,7 @@ const createKubectlInjectable = getInjectable({
normalizedDownloadPlatform: di.inject(normalizedPlatformInjectable),
kubectlBinaryName: di.inject(kubectlBinaryNameInjectable),
bundledKubectlBinaryPath: di.inject(bundledKubectlBinaryPathInjectable),
baseBundeledBinariesDirectory: di.inject(baseBundeledBinariesDirectoryInjectable),
baseBundeledBinariesDirectory: di.inject(baseBundledBinariesDirectoryInjectable),
};
return (clusterVersion: string) => new Kubectl(dependencies, clusterVersion);

View File

@ -5,25 +5,29 @@
import { getRouteInjectable } from "../../../router/router.injectable";
import { apiPrefix } from "../../../../common/vars";
import { route } from "../../../router/route";
import { helmService } from "../../../helm/helm-service";
import getHelmChartInjectable from "../../../helm/helm-service/get-helm-chart.injectable";
const getChartRouteInjectable = getRouteInjectable({
id: "get-chart-route",
instantiate: () => route({
method: "get",
path: `${apiPrefix}/v2/charts/{repo}/{chart}`,
})(async ({ params, query }) => {
const { repo, chart } = params;
instantiate: (di) => {
const getHelmChart = di.inject(getHelmChartInjectable);
return {
response: await helmService.getChart(
repo,
chart,
query.get("version") ?? undefined,
),
};
}),
return route({
method: "get",
path: `${apiPrefix}/v2/charts/{repo}/{chart}`,
})(async ({ params, query }) => {
const { repo, chart } = params;
return {
response: await getHelmChart(
repo,
chart,
query.get("version") ?? undefined,
),
};
});
},
});
export default getChartRouteInjectable;

View File

@ -3,23 +3,27 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getRouteInjectable } from "../../../router/router.injectable";
import { helmService } from "../../../helm/helm-service";
import { apiPrefix } from "../../../../common/vars";
import { route } from "../../../router/route";
import getHelmChartValuesInjectable from "../../../helm/helm-service/get-helm-chart-values.injectable";
const getChartRouteValuesInjectable = getRouteInjectable({
id: "get-chart-route-values",
instantiate: () => route({
method: "get",
path: `${apiPrefix}/v2/charts/{repo}/{chart}/values`,
})(async ({ params, query }) => ({
response: await helmService.getChartValues(
params.repo,
params.chart,
query.get("version") ?? undefined,
),
})),
instantiate: (di) => {
const getHelmChartValues = di.inject(getHelmChartValuesInjectable);
return route({
method: "get",
path: `${apiPrefix}/v2/charts/{repo}/{chart}/values`,
})(async ({ params, query }) => ({
response: await getHelmChartValues(
params.repo,
params.chart,
query.get("version") ?? undefined,
),
}));
},
});
export default getChartRouteValuesInjectable;

View File

@ -3,19 +3,23 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getRouteInjectable } from "../../../router/router.injectable";
import { helmService } from "../../../helm/helm-service";
import { apiPrefix } from "../../../../common/vars";
import { route } from "../../../router/route";
import listHelmChartsInjectable from "../../../helm/helm-service/list-helm-charts.injectable";
const listChartsRouteInjectable = getRouteInjectable({
id: "list-charts-route",
instantiate: () => route({
method: "get",
path: `${apiPrefix}/v2/charts`,
})(async () => ({
response: await helmService.listCharts(),
})),
instantiate: (di) => {
const listHelmCharts = di.inject(listHelmChartsInjectable);
return route({
method: "get",
path: `${apiPrefix}/v2/charts`,
})(async () => ({
response: await listHelmCharts(),
}));
},
});
export default listChartsRouteInjectable;

View File

@ -3,23 +3,23 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { apiPrefix } from "../../../../common/vars";
import { helmService } from "../../../helm/helm-service";
import { getRouteInjectable } from "../../../router/router.injectable";
import { clusterRoute } from "../../../router/route";
import deleteHelmReleaseInjectable from "../../../helm/helm-service/delete-helm-release.injectable";
const deleteReleaseRouteInjectable = getRouteInjectable({
id: "delete-release-route",
instantiate: () => clusterRoute({
method: "delete",
path: `${apiPrefix}/v2/releases/{namespace}/{release}`,
})(async ({ cluster, params: { release, namespace }}) => ({
response: await helmService.deleteRelease(
cluster,
release,
namespace,
),
})),
instantiate: (di) => {
const deleteHelmRelease = di.inject(deleteHelmReleaseInjectable);
return clusterRoute({
method: "delete",
path: `${apiPrefix}/v2/releases/{namespace}/{release}`,
})(async ({ cluster, params: { release, namespace }}) => ({
response: await deleteHelmRelease(cluster, release, namespace),
}));
},
});
export default deleteReleaseRouteInjectable;

View File

@ -3,23 +3,27 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { apiPrefix } from "../../../../common/vars";
import { helmService } from "../../../helm/helm-service";
import { getRouteInjectable } from "../../../router/router.injectable";
import { clusterRoute } from "../../../router/route";
import getHelmReleaseHistoryInjectable from "../../../helm/helm-service/get-helm-release-history.injectable";
const getReleaseRouteHistoryInjectable = getRouteInjectable({
id: "get-release-history-route",
instantiate: () => clusterRoute({
method: "get",
path: `${apiPrefix}/v2/releases/{namespace}/{release}/history`,
})(async ({ cluster, params }) => ({
response: await helmService.getReleaseHistory(
cluster,
params.release,
params.namespace,
),
})),
instantiate: (di) => {
const getHelmReleaseHistory = di.inject(getHelmReleaseHistoryInjectable);
return clusterRoute({
method: "get",
path: `${apiPrefix}/v2/releases/{namespace}/{release}/history`,
})(async ({ cluster, params }) => ({
response: await getHelmReleaseHistory(
cluster,
params.release,
params.namespace,
),
}));
},
});
export default getReleaseRouteHistoryInjectable;

View File

@ -3,23 +3,27 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { apiPrefix } from "../../../../common/vars";
import { helmService } from "../../../helm/helm-service";
import { getRouteInjectable } from "../../../router/router.injectable";
import { clusterRoute } from "../../../router/route";
import getHelmReleaseInjectable from "../../../helm/helm-service/get-helm-release.injectable";
const getReleaseRouteInjectable = getRouteInjectable({
id: "get-release-route",
instantiate: () => clusterRoute({
method: "get",
path: `${apiPrefix}/v2/releases/{namespace}/{release}`,
})(async ({ cluster, params }) => ({
response: await helmService.getRelease(
cluster,
params.release,
params.namespace,
),
})),
instantiate: (di) => {
const getHelmRelease = di.inject(getHelmReleaseInjectable);
return clusterRoute({
method: "get",
path: `${apiPrefix}/v2/releases/{namespace}/{release}`,
})(async ({ cluster, params }) => ({
response: await getHelmRelease(
cluster,
params.release,
params.namespace,
),
}));
},
});
export default getReleaseRouteInjectable;

View File

@ -3,27 +3,31 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { apiPrefix } from "../../../../common/vars";
import { helmService } from "../../../helm/helm-service";
import { getRouteInjectable } from "../../../router/router.injectable";
import { getBoolean } from "../../../utils/parse-query";
import { contentTypes } from "../../../router/router-content-types";
import { clusterRoute } from "../../../router/route";
import getHelmReleaseValuesInjectable from "../../../helm/helm-service/get-helm-release-values.injectable";
const getReleaseRouteValuesInjectable = getRouteInjectable({
id: "get-release-values-route",
instantiate: () => clusterRoute({
method: "get",
path: `${apiPrefix}/v2/releases/{namespace}/{release}/values`,
})(async ({ cluster, params: { namespace, release }, query }) => ({
response: await helmService.getReleaseValues(release, {
cluster,
namespace,
all: getBoolean(query, "all"),
}),
instantiate: (di) => {
const getHelmReleaseValues = di.inject(getHelmReleaseValuesInjectable);
contentType: contentTypes.txt,
})),
return clusterRoute({
method: "get",
path: `${apiPrefix}/v2/releases/{namespace}/{release}/values`,
})(async ({ cluster, params: { namespace, release }, query }) => ({
response: await getHelmReleaseValues(release, {
cluster,
namespace,
all: getBoolean(query, "all"),
}),
contentType: contentTypes.txt,
}));
},
});
export default getReleaseRouteValuesInjectable;

View File

@ -3,11 +3,11 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { apiPrefix } from "../../../../common/vars";
import type { InstallChartArgs } from "../../../helm/helm-service";
import { helmService } from "../../../helm/helm-service";
import { getRouteInjectable } from "../../../router/router.injectable";
import Joi from "joi";
import { payloadValidatedClusterRoute } from "../../../router/route";
import type { InstallChartArgs } from "../../../helm/helm-service/install-helm-chart.injectable";
import installHelmChartInjectable from "../../../helm/helm-service/install-helm-chart.injectable";
const installChartArgsValidator = Joi.object<InstallChartArgs, true, InstallChartArgs>({
chart: Joi
@ -31,14 +31,18 @@ const installChartArgsValidator = Joi.object<InstallChartArgs, true, InstallChar
const installChartRouteInjectable = getRouteInjectable({
id: "install-chart-route",
instantiate: () => payloadValidatedClusterRoute({
method: "post",
path: `${apiPrefix}/v2/releases`,
payloadValidator: installChartArgsValidator,
})(async ({ payload, cluster }) => ({
response: await helmService.installChart(cluster, payload),
statusCode: 201,
})),
instantiate: (di) => {
const installHelmChart = di.inject(installHelmChartInjectable);
return payloadValidatedClusterRoute({
method: "post",
path: `${apiPrefix}/v2/releases`,
payloadValidator: installChartArgsValidator,
})(async ({ payload, cluster }) => ({
response: await installHelmChart(cluster, payload),
statusCode: 201,
}));
},
});
export default installChartRouteInjectable;

View File

@ -3,19 +3,23 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { apiPrefix } from "../../../../common/vars";
import { helmService } from "../../../helm/helm-service";
import { getRouteInjectable } from "../../../router/router.injectable";
import { clusterRoute } from "../../../router/route";
import listHelmReleasesInjectable from "../../../helm/helm-service/list-helm-releases.injectable";
const listReleasesRouteInjectable = getRouteInjectable({
id: "list-releases-route",
instantiate: () => clusterRoute({
method: "get",
path: `${apiPrefix}/v2/releases/{namespace?}`,
})(async ({ cluster, params }) => ({
response: await helmService.listReleases(cluster, params.namespace),
})),
instantiate: (di) => {
const listHelmReleases = di.inject(listHelmReleasesInjectable);
return clusterRoute({
method: "get",
path: `${apiPrefix}/v2/releases/{namespace?}`,
})(async ({ cluster, params }) => ({
response: await listHelmReleases(cluster, params.namespace),
}));
},
});
export default listReleasesRouteInjectable;

View File

@ -3,10 +3,10 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { apiPrefix } from "../../../../common/vars";
import { helmService } from "../../../helm/helm-service";
import { getRouteInjectable } from "../../../router/router.injectable";
import Joi from "joi";
import { payloadValidatedClusterRoute } from "../../../router/route";
import rollbackHelmReleaseInjectable from "../../../helm/helm-service/rollback-helm-release.injectable";
interface RollbackReleasePayload {
revision: number;
@ -21,13 +21,17 @@ const rollbackReleasePayloadValidator = Joi.object<RollbackReleasePayload, true,
const rollbackReleaseRouteInjectable = getRouteInjectable({
id: "rollback-release-route",
instantiate: () => payloadValidatedClusterRoute({
method: "put",
path: `${apiPrefix}/v2/releases/{namespace}/{release}/rollback`,
payloadValidator: rollbackReleasePayloadValidator,
})(async ({ cluster, params: { release, namespace }, payload }) => {
await helmService.rollback(cluster, release, namespace, payload.revision);
}),
instantiate: (di) => {
const rollbackRelease = di.inject(rollbackHelmReleaseInjectable);
return payloadValidatedClusterRoute({
method: "put",
path: `${apiPrefix}/v2/releases/{namespace}/{release}/rollback`,
payloadValidator: rollbackReleasePayloadValidator,
})(async ({ cluster, params: { release, namespace }, payload }) => {
await rollbackRelease(cluster, release, namespace, payload.revision);
});
},
});
export default rollbackReleaseRouteInjectable;

View File

@ -3,11 +3,11 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { apiPrefix } from "../../../../common/vars";
import type { UpdateChartArgs } from "../../../helm/helm-service";
import { helmService } from "../../../helm/helm-service";
import { getRouteInjectable } from "../../../router/router.injectable";
import { payloadValidatedClusterRoute } from "../../../router/route";
import Joi from "joi";
import type { UpdateChartArgs } from "../../../helm/helm-service/update-helm-release.injectable";
import updateHelmReleaseInjectable from "../../../helm/helm-service/update-helm-release.injectable";
const updateChartArgsValidator = Joi.object<UpdateChartArgs, true, UpdateChartArgs>({
chart: Joi
@ -24,18 +24,22 @@ const updateChartArgsValidator = Joi.object<UpdateChartArgs, true, UpdateChartAr
const updateReleaseRouteInjectable = getRouteInjectable({
id: "update-release-route",
instantiate: () => payloadValidatedClusterRoute({
method: "put",
path: `${apiPrefix}/v2/releases/{namespace}/{release}`,
payloadValidator: updateChartArgsValidator,
})(async ({ cluster, params, payload }) => ({
response: await helmService.updateRelease(
cluster,
params.release,
params.namespace,
payload,
),
})),
instantiate: (di) => {
const updateRelease = di.inject(updateHelmReleaseInjectable);
return payloadValidatedClusterRoute({
method: "put",
path: `${apiPrefix}/v2/releases/{namespace}/{release}`,
payloadValidator: updateChartArgsValidator,
})(async ({ cluster, params, payload }) => ({
response: await updateRelease(
cluster,
params.release,
params.namespace,
payload,
),
}));
},
});
export default updateReleaseRouteInjectable;

View File

@ -15,7 +15,6 @@ import * as LensExtensionsCommonApi from "../extensions/common-api";
import * as LensExtensionsRendererApi from "../extensions/renderer-api";
import { delay } from "../common/utils";
import { isMac, isDevelopment } from "../common/vars";
import { HelmRepoManager } from "../main/helm/helm-repo-manager";
import { DefaultProps } from "./mui-base-theme";
import configurePackages from "../common/configure-packages";
import * as initializers from "./initializers";
@ -151,8 +150,6 @@ export async function bootstrap(di: DiContainer) {
extensionInstallationStateStore.bindIpcListeners();
HelmRepoManager.createInstance(); // initialize the manager
// Register additional store listeners
clusterStore.registerIpcListener();

View File

@ -1,220 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import "./add-helm-repo-dialog.scss";
import React from "react";
import type { FileFilter } from "electron";
import { observable, makeObservable, action } from "mobx";
import { observer } from "mobx-react";
import type { DialogProps } from "../dialog";
import { Dialog } from "../dialog";
import { Wizard, WizardStep } from "../wizard";
import { Input } from "../input";
import { Checkbox } from "../checkbox";
import { Button } from "../button";
import { systemName, isUrl, isPath } from "../input/input_validators";
import { SubTitle } from "../layout/sub-title";
import { Icon } from "../icon";
import { Notifications } from "../notifications";
import { type HelmRepo, HelmRepoManager } from "../../../main/helm/helm-repo-manager";
import { requestOpenFilePickingDialog } from "../../ipc";
export interface AddHelmRepoDialogProps extends Partial<DialogProps> {
onAddRepo: () => void;
}
enum FileType {
CaFile = "caFile",
KeyFile = "keyFile",
CertFile = "certFile",
}
const dialogState = observable.object({
isOpen: false,
});
function getEmptyRepo(): HelmRepo {
return { name: "", url: "", username: "", password: "", insecureSkipTlsVerify: false, caFile: "", keyFile: "", certFile: "" };
}
@observer
export class AddHelmRepoDialog extends React.Component<AddHelmRepoDialogProps> {
private static keyExtensions = ["key", "keystore", "jks", "p12", "pfx", "pem"];
private static certExtensions = ["crt", "cer", "ca-bundle", "p7b", "p7c", "p7s", "p12", "pfx", "pem"];
constructor(props: AddHelmRepoDialogProps) {
super(props);
makeObservable(this);
}
static open() {
dialogState.isOpen = true;
}
static close() {
dialogState.isOpen = false;
}
@observable helmRepo = getEmptyRepo();
@observable showOptions = false;
@action
close = () => {
AddHelmRepoDialog.close();
this.helmRepo = getEmptyRepo();
this.showOptions = false;
};
setFilepath(type: FileType, value: string) {
this.helmRepo[type] = value;
}
getFilePath(type: FileType) {
return this.helmRepo[type];
}
async selectFileDialog(type: FileType, fileFilter: FileFilter) {
const { canceled, filePaths } = await requestOpenFilePickingDialog({
defaultPath: this.getFilePath(type),
properties: ["openFile", "showHiddenFiles"],
message: `Select file`,
buttonLabel: `Use file`,
filters: [
fileFilter,
{ name: "Any", extensions: ["*"] },
],
});
if (!canceled && filePaths.length) {
this.setFilepath(type, filePaths[0]);
}
}
async addCustomRepo() {
try {
await HelmRepoManager.getInstance().addRepo(this.helmRepo);
Notifications.ok((
<>
{"Helm repository "}
<b>{this.helmRepo.name}</b>
{" has been added."}
</>
));
this.props.onAddRepo();
this.close();
} catch (err) {
Notifications.error((
<>
{"Adding helm branch "}
<b>{this.helmRepo.name}</b>
{" has failed: "}
{String(err)}
</>
));
}
}
renderFileInput(placeholder:string, fileType:FileType, fileExtensions:string[]){
return (
<div className="flex gaps align-center">
<Input
placeholder={placeholder}
validators={isPath}
className="box grow"
value={this.getFilePath(fileType)}
onChange={v => this.setFilepath(fileType, v)}
/>
<Icon
material="folder"
onClick={() => this.selectFileDialog(fileType, { name: placeholder, extensions: fileExtensions })}
tooltip="Browse"
/>
</div>
);
}
renderOptions() {
return (
<>
<SubTitle title="Security settings" />
<Checkbox
label="Skip TLS certificate checks for the repository"
value={this.helmRepo.insecureSkipTlsVerify}
onChange={v => this.helmRepo.insecureSkipTlsVerify = v}
/>
{this.renderFileInput("Key file", FileType.KeyFile, AddHelmRepoDialog.keyExtensions)}
{this.renderFileInput("Ca file", FileType.CaFile, AddHelmRepoDialog.certExtensions)}
{this.renderFileInput("Certificate file", FileType.CertFile, AddHelmRepoDialog.certExtensions)}
<SubTitle title="Chart Repository Credentials" />
<Input
placeholder="Username"
value={this.helmRepo.username}
onChange= {v => this.helmRepo.username = v}
/>
<Input
type="password"
placeholder="Password"
value={this.helmRepo.password}
onChange={v => this.helmRepo.password = v}
/>
</>
);
}
render() {
const { ...dialogProps } = this.props;
const header = <h5>Add custom Helm Repo</h5>;
return (
<Dialog
{...dialogProps}
className="AddHelmRepoDialog"
isOpen={dialogState.isOpen}
close={this.close}
>
<Wizard header={header} done={this.close}>
<WizardStep
contentClass="flow column"
nextLabel="Add"
next={() => this.addCustomRepo()}
>
<div className="flex column gaps">
<Input
autoFocus
required
placeholder="Helm repo name"
trim
validators={systemName}
value={this.helmRepo.name}
onChange={v => this.helmRepo.name = v}
/>
<Input
required
placeholder="URL"
validators={isUrl}
value={this.helmRepo.url}
onChange={v => this.helmRepo.url = v}
/>
<Button
plain
className="accordion"
onClick={() => this.showOptions = !this.showOptions}
>
More
<Icon
small
tooltip="More"
material={this.showOptions ? "remove" : "add"}
/>
</Button>
{this.showOptions && this.renderOptions()}
</div>
</WizardStep>
</Wizard>
</Dialog>
);
}
}

View File

@ -1,202 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import styles from "./helm-charts.module.scss";
import React from "react";
import { observable, makeObservable, computed } from "mobx";
import type { HelmRepo } from "../../../main/helm/helm-repo-manager";
import { HelmRepoManager } from "../../../main/helm/helm-repo-manager";
import { Button } from "../button";
import { Icon } from "../icon";
import { Notifications } from "../notifications";
import type { SelectOption } from "../select";
import { Select } from "../select";
import { AddHelmRepoDialog } from "./add-helm-repo-dialog";
import { observer } from "mobx-react";
import { RemovableItem } from "./removable-item";
import { Notice } from "../+extensions/notice";
import { Spinner } from "../spinner";
import { noop } from "../../utils";
import type { SingleValue } from "react-select";
@observer
export class HelmCharts extends React.Component {
@observable loadingRepos = false;
@observable loadingAvailableRepos = false;
@observable repos: HelmRepo[] = [];
@observable addedRepos = observable.map<string, HelmRepo>();
constructor(props: {}) {
super(props);
makeObservable(this);
}
@computed get repoOptions() {
return this.repos.map(repo => ({
value: repo,
label: repo.name,
isSelected: this.addedRepos.has(repo.name),
}));
}
componentDidMount() {
this.loadAvailableRepos().catch(noop);
this.loadRepos().catch(noop);
}
async loadAvailableRepos() {
this.loadingAvailableRepos = true;
try {
if (!this.repos.length) {
this.repos = await HelmRepoManager.getInstance().loadAvailableRepos();
}
} catch (err) {
Notifications.error(String(err));
}
this.loadingAvailableRepos = false;
}
async loadRepos() {
this.loadingRepos = true;
try {
const repos = await HelmRepoManager.getInstance().repositories(); // via helm-cli
this.addedRepos.replace(repos.map(repo => [repo.name, repo]));
} catch (err) {
Notifications.error(String(err));
}
this.loadingRepos = false;
}
async addRepo(repo: HelmRepo) {
try {
await HelmRepoManager.getInstance().addRepo(repo);
this.addedRepos.set(repo.name, repo);
} catch (err) {
Notifications.error((
<>
{"Adding helm branch "}
<b>{repo.name}</b>
{" has failed: "}
{String(err)}
</>
));
}
}
async removeRepo(repo: HelmRepo) {
try {
await HelmRepoManager.getInstance().removeRepo(repo);
this.addedRepos.delete(repo.name);
} catch (err) {
Notifications.error(
<>
{"Removing helm branch "}
<b>{repo.name}</b>
{" has failed: "}
{String(err)}
</>,
);
}
}
onRepoSelect = async (option: SingleValue<{ value: HelmRepo }>): Promise<void> => {
if (!option) {
return;
}
if (this.addedRepos.has(option.value.name)) {
return void Notifications.ok((
<>
{"Helm repo "}
<b>{option.value.name}</b>
{" already in use."}
</>
));
}
await this.addRepo(option.value);
};
formatOptionLabel = ({ value, isSelected }: SelectOption<HelmRepo>) => (
<div className="flex gaps">
<span>{value.name}</span>
{isSelected && (
<Icon
small
material="check"
className="box right" />
)}
</div>
);
renderRepositories() {
const repos = Array.from(this.addedRepos);
if (this.loadingRepos) {
return <div className="pt-5 relative"><Spinner center/></div>;
}
if (!repos.length) {
return (
<Notice>
<div className="flex-grow text-center">The repositories have not been added yet</div>
</Notice>
);
}
return repos.map(([name, repo]) => {
return (
<RemovableItem
key={name}
onRemove={() => this.removeRepo(repo)}
className="mt-3"
>
<div>
<div data-testid="repository-name" className={styles.repoName}>{name}</div>
<div className={styles.repoUrl}>{repo.url}</div>
</div>
</RemovableItem>
);
});
}
render() {
return (
<div>
<div className="flex gaps">
<Select
id="HelmRepoSelect"
placeholder="Repositories"
isLoading={this.loadingAvailableRepos}
isDisabled={this.loadingAvailableRepos}
options={this.repoOptions}
onChange={this.onRepoSelect}
value={this.repos}
formatOptionLabel={this.formatOptionLabel}
controlShouldRenderValue={false}
className="box grow"
themeName="lens"
/>
<Button
primary
label="Add Custom Helm Repo"
onClick={AddHelmRepoDialog.open}
/>
</div>
<AddHelmRepoDialog onAddRepo={() => this.loadRepos()}/>
<div className={styles.repos}>
{this.renderRepositories()}
</div>
</div>
);
}
}

View File

@ -4,8 +4,7 @@
*/
import { observer } from "mobx-react";
import React from "react";
import { HelmCharts } from "./helm-charts";
import { HelmCharts } from "./kubernetes/helm-charts/helm-charts";
import { KubeconfigSyncs } from "./kubeconfig-syncs";
import { KubectlBinaries } from "./kubectl-binaries";
import { Preferences } from "./preferences";

View File

@ -0,0 +1,43 @@
/**
* 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 { asyncComputed } from "@ogre-tools/injectable-react";
import getActiveHelmRepositoriesChannelInjectable from "../../../../../common/helm/get-active-helm-repositories-channel.injectable";
import { requestFromChannelInjectionToken } from "../../../../../common/utils/channel/request-from-channel-injection-token";
import showErrorNotificationInjectable from "../../../notifications/show-error-notification.injectable";
import helmRepositoriesErrorStateInjectable from "./helm-repositories-error-state.injectable";
import { runInAction } from "mobx";
const activeHelmRepositoriesInjectable = getInjectable({
id: "active-helm-repositories",
instantiate: (di) => {
const requestFromChannel = di.inject(requestFromChannelInjectionToken);
const getHelmRepositoriesChannel = di.inject(getActiveHelmRepositoriesChannelInjectable);
const showErrorNotification = di.inject(showErrorNotificationInjectable);
const helmRepositoriesErrorState = di.inject(helmRepositoriesErrorStateInjectable);
return asyncComputed(async () => {
const result = await requestFromChannel(getHelmRepositoriesChannel);
if (result.callWasSuccessful) {
return result.response;
} else {
showErrorNotification(result.error);
runInAction(() =>
helmRepositoriesErrorState.set({
controlsAreShown: false,
errorMessage: result.error,
}),
);
return [];
}
}, []);
},
});
export default activeHelmRepositoriesInjectable;

View File

@ -0,0 +1,152 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import "./add-helm-repo-dialog.scss";
import React from "react";
import { Wizard, WizardStep } from "../../../../wizard";
import { Input } from "../../../../input";
import { systemName, isUrl } from "../../../../input/input_validators";
import { withInjectables } from "@ogre-tools/injectable-react";
import customHelmRepoInjectable from "./custom-helm-repo.injectable";
import type { HelmRepo } from "../../../../../../common/helm/helm-repo";
import { observer } from "mobx-react";
import type { IObservableValue } from "mobx";
import { action } from "mobx";
import submitCustomHelmRepositoryInjectable from "./submit-custom-helm-repository.injectable";
import hideDialogForAddingCustomHelmRepositoryInjectable from "./dialog-visibility/hide-dialog-for-adding-custom-helm-repository.injectable";
import { Button } from "../../../../button";
import { Icon } from "../../../../icon";
import maximalCustomHelmRepoOptionsAreShownInjectable from "./maximal-custom-helm-repo-options-are-shown.injectable";
import { SubTitle } from "../../../../layout/sub-title";
import { Checkbox } from "../../../../checkbox";
import { HelmFileInput } from "./helm-file-input/helm-file-input";
interface Dependencies {
helmRepo: HelmRepo;
hideDialog: () => void;
submitCustomRepository: (repository: HelmRepo) => Promise<void>;
maximalOptionsAreShown: IObservableValue<boolean>;
}
const NonInjectedActivationOfCustomHelmRepositoryDialogContent = observer(({ helmRepo, submitCustomRepository, maximalOptionsAreShown, hideDialog } : Dependencies) => (
<Wizard header={<h5>Add custom Helm Repo</h5>} done={hideDialog}>
<WizardStep
contentClass="flow column"
nextLabel="Add"
next={() => submitCustomRepository(helmRepo)}
testIdForNext="custom-helm-repository-submit-button"
testIdForPrev="custom-helm-repository-cancel-button"
>
<div className="flex column gaps" data-testid="add-custom-helm-repository-dialog">
<Input
autoFocus
required
placeholder="Helm repo name"
trim
validators={systemName}
value={helmRepo.name}
onChange={action(v => helmRepo.name = v)}
data-testid="custom-helm-repository-name-input"
/>
<Input
required
placeholder="URL"
validators={isUrl}
value={helmRepo.url}
onChange={action(v => helmRepo.url = v)}
data-testid="custom-helm-repository-url-input"
/>
<Button
plain
className="accordion"
data-testid="toggle-maximal-options-for-custom-helm-repository-button"
onClick={action(() => maximalOptionsAreShown.set(!maximalOptionsAreShown.get()))}
>
More
<Icon
small
tooltip="More"
material={maximalOptionsAreShown.get() ? "remove" : "add"}
/>
</Button>
{maximalOptionsAreShown.get() && (
<div data-testid="maximal-options-for-custom-helm-repository-dialog">
<SubTitle title="Security settings" />
<Checkbox
label="Skip TLS certificate checks for the repository"
value={helmRepo.insecureSkipTlsVerify}
onChange={action(v => {
helmRepo.insecureSkipTlsVerify = v;
})}
data-testid="custom-helm-repository-verify-tls-input"
/>
<HelmFileInput
placeholder="Key file"
value={helmRepo.keyFile || ""}
setValue={action((value) => helmRepo.keyFile = value)}
fileExtensions={keyExtensions}
data-testid="custom-helm-repository-key-file-input"
/>
<HelmFileInput
placeholder="Ca file"
value={helmRepo.caFile || ""}
setValue={action((value) => helmRepo.caFile = value)}
fileExtensions={certExtensions}
data-testid="custom-helm-repository-ca-cert-file-input"
/>
<HelmFileInput
placeholder="Certificate file"
value={helmRepo.certFile || ""}
setValue={action((value) => helmRepo.certFile = value)}
fileExtensions={certExtensions}
data-testid="custom-helm-repository-cert-file-input"
/>
<SubTitle title="Chart Repository Credentials" />
<Input
placeholder="Username"
value={helmRepo.username}
onChange= {action(v => helmRepo.username = v)}
data-testid="custom-helm-repository-username-input"
/>
<Input
type="password"
placeholder="Password"
value={helmRepo.password}
onChange={action(v => helmRepo.password = v)}
data-testid="custom-helm-repository-password-input"
/>
</div>
)}
</div>
</WizardStep>
</Wizard>
));
export const AddingOfCustomHelmRepositoryDialogContent = withInjectables<Dependencies>(
NonInjectedActivationOfCustomHelmRepositoryDialogContent,
{
getProps: (di) => ({
helmRepo: di.inject(customHelmRepoInjectable),
hideDialog: di.inject(hideDialogForAddingCustomHelmRepositoryInjectable),
submitCustomRepository: di.inject(submitCustomHelmRepositoryInjectable),
maximalOptionsAreShown: di.inject(maximalCustomHelmRepoOptionsAreShownInjectable),
}),
},
);
const keyExtensions = ["key", "keystore", "jks", "p12", "pfx", "pem"];
const certExtensions = ["crt", "cer", "ca-bundle", "p7b", "p7c", "p7s", "p12", "pfx", "pem"];

View File

@ -0,0 +1,47 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import "./add-helm-repo-dialog.scss";
import React from "react";
import { Dialog } from "../../../../dialog";
import { withInjectables } from "@ogre-tools/injectable-react";
import { AddingOfCustomHelmRepositoryDialogContent } from "./adding-of-custom-helm-repository-dialog-content";
import addingOfCustomHelmRepositoryDialogIsVisibleInjectable from "./dialog-visibility/adding-of-custom-helm-repository-dialog-is-visible.injectable";
import type { IObservableValue } from "mobx";
import { observer } from "mobx-react";
import hideDialogForAddingCustomHelmRepositoryInjectable from "./dialog-visibility/hide-dialog-for-adding-custom-helm-repository.injectable";
interface Dependencies {
contentIsVisible: IObservableValue<boolean>;
hideDialog: () => void;
}
const NonInjectedActivationOfCustomHelmRepositoryDialog = observer(({
contentIsVisible,
hideDialog,
}: Dependencies) => (
<div>
<Dialog
className="AddHelmRepoDialog"
isOpen={contentIsVisible.get()}
close={hideDialog}
>
{contentIsVisible.get() && <AddingOfCustomHelmRepositoryDialogContent />}
</Dialog>
</div>
));
export const AddingOfCustomHelmRepositoryDialog = withInjectables<Dependencies>(
NonInjectedActivationOfCustomHelmRepositoryDialog,
{
getProps: (di) => ({
contentIsVisible: di.inject(addingOfCustomHelmRepositoryDialogIsVisibleInjectable),
hideDialog: di.inject(hideDialogForAddingCustomHelmRepositoryInjectable),
}),
},
);

View File

@ -0,0 +1,32 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { withInjectables } from "@ogre-tools/injectable-react";
import React from "react";
import { Button } from "../../../../button";
import showDialogForAddingCustomHelmRepositoryInjectable from "./dialog-visibility/show-dialog-for-adding-custom-helm-repository.injectable";
interface Dependencies {
showDialog: () => void;
}
const NonInjectedActivationOfCustomHelmRepositoryOpenButton = ({ showDialog }: Dependencies) => (
<Button
primary
label="Add Custom Helm Repo"
onClick={showDialog}
data-testid="add-custom-helm-repo-button"
/>
);
export const AddingOfCustomHelmRepositoryOpenButton = withInjectables<Dependencies>(
NonInjectedActivationOfCustomHelmRepositoryOpenButton,
{
getProps: (di) => ({
showDialog: di.inject(showDialogForAddingCustomHelmRepositoryInjectable),
}),
},
);

View File

@ -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, lifecycleEnum } from "@ogre-tools/injectable";
import { observable } from "mobx";
const customHelmRepoInjectable = getInjectable({
id: "custom-helm-repo",
instantiate: () => observable({
name: "",
url: "",
username: "",
password: "",
insecureSkipTlsVerify: false,
caFile: "",
keyFile: "",
certFile: "",
}),
lifecycle: lifecycleEnum.transient,
});
export default customHelmRepoInjectable;

View File

@ -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 { observable } from "mobx";
const addingOfCustomHelmRepositoryDialogIsVisibleInjectable = getInjectable({
id: "adding-of-custom-helm-repository-dialog-is-visible",
instantiate: () => observable.box(false),
});
export default addingOfCustomHelmRepositoryDialogIsVisibleInjectable;

View File

@ -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 { action } from "mobx";
import addingOfCustomHelmRepositoryDialogIsVisibleInjectable from "./adding-of-custom-helm-repository-dialog-is-visible.injectable";
const hideDialogForAddingCustomHelmRepositoryInjectable = getInjectable({
id: "hide-dialog-for-adding-custom-helm-repository",
instantiate: (di) => {
const state = di.inject(addingOfCustomHelmRepositoryDialogIsVisibleInjectable);
return action(() => {
state.set(false);
});
},
});
export default hideDialogForAddingCustomHelmRepositoryInjectable;

View File

@ -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 { action } from "mobx";
import addingOfCustomHelmRepositoryDialogIsVisibleInjectable from "./adding-of-custom-helm-repository-dialog-is-visible.injectable";
const showDialogForAddingCustomHelmRepositoryInjectable = getInjectable({
id: "show-dialog-for-adding-custom-helm-repository",
instantiate: (di) => {
const state = di.inject(addingOfCustomHelmRepositoryDialogIsVisibleInjectable);
return action(() => {
state.set(true);
});
},
});
export default showDialogForAddingCustomHelmRepositoryInjectable;

View File

@ -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 type { FileFilter } from "electron";
import { requestOpenFilePickingDialog } from "../../../../../../ipc";
const getFilePathsInjectable = getInjectable({
id: "get-file-paths",
instantiate: () => async (fileFilter: FileFilter) =>
await requestOpenFilePickingDialog({
properties: ["openFile", "showHiddenFiles"],
message: `Select file`,
buttonLabel: `Use file`,
filters: [fileFilter, { name: "Any", extensions: ["*"] }],
}),
causesSideEffects: true,
});
export default getFilePathsInjectable;

View File

@ -0,0 +1,74 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { InputValidator } from "../../../../../input";
import { Input } from "../../../../../input";
import { Icon } from "../../../../../icon";
import { withInjectables } from "@ogre-tools/injectable-react";
import React from "react";
import getFilePathsInjectable from "./get-file-paths.injectable";
import type { FileFilter } from "electron";
import isPathInjectable from "../../../../../input/validators/is-path.injectable";
interface HelmFileInputProps {
placeholder: string;
fileExtensions: string[];
value: string;
setValue: (value: string) => void;
"data-testid"?: string;
}
interface Dependencies {
getFilePaths: (fileFilter: FileFilter) => Promise<{ canceled: boolean; filePaths: string[] }>;
isPath: InputValidator<true>;
}
const NonInjectedHelmFileInput = ({
placeholder,
value,
setValue,
fileExtensions,
getFilePaths,
isPath,
"data-testid": testId,
}: Dependencies & HelmFileInputProps) => (
<div className="flex gaps align-center">
<Input
placeholder={placeholder}
validators={isPath}
className="box grow"
value={value}
onChange={(v) => setValue(v)}
data-testid={testId}
/>
<Icon
material="folder"
onClick={async () => {
const { canceled, filePaths } = await getFilePaths({
name: placeholder,
extensions: fileExtensions,
});
if (!canceled && filePaths.length) {
setValue(filePaths[0]);
}
}}
tooltip="Browse"
/>
</div>
);
export const HelmFileInput = withInjectables<Dependencies, HelmFileInputProps>(
NonInjectedHelmFileInput,
{
getProps: (di, props) => ({
getFilePaths: di.inject(getFilePathsInjectable),
isPath: di.inject(isPathInjectable),
...props,
}),
},
);

View File

@ -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 { observable } from "mobx";
const maximalCustomHelmRepoOptionsAreShownInjectable = getInjectable({
id: "maximal-custom-helm-repo-options-are-shown",
instantiate: () => observable.box(false),
});
export default maximalCustomHelmRepoOptionsAreShownInjectable;

View File

@ -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 type { HelmRepo } from "../../../../../../common/helm/helm-repo";
import addHelmRepositoryInjectable from "../adding-of-public-helm-repository/select-helm-repository/add-helm-repository.injectable";
import hideDialogForAddingCustomHelmRepositoryInjectable from "./dialog-visibility/hide-dialog-for-adding-custom-helm-repository.injectable";
const submitCustomHelmRepositoryInjectable = getInjectable({
id: "submit-custom-helm-repository",
instantiate: (di) => {
const addHelmRepository = di.inject(addHelmRepositoryInjectable);
const hideDialog = di.inject(hideDialogForAddingCustomHelmRepositoryInjectable);
return async (repository: HelmRepo) => {
await addHelmRepository(repository);
hideDialog();
};
},
});
export default submitCustomHelmRepositoryInjectable;

View File

@ -0,0 +1,80 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { IAsyncComputed } from "@ogre-tools/injectable-react";
import { withInjectables } from "@ogre-tools/injectable-react";
import React from "react";
import publicHelmRepositoriesInjectable from "./public-helm-repositories/public-helm-repositories.injectable";
import type { HelmRepo } from "../../../../../../common/helm/helm-repo";
import type { SelectOption } from "../../../../select";
import { Select } from "../../../../select";
import { Icon } from "../../../../icon";
import { observer } from "mobx-react";
import type { SingleValue } from "react-select";
import selectHelmRepositoryInjectable from "./select-helm-repository/select-helm-repository.injectable";
import { matches } from "lodash/fp";
import activeHelmRepositoriesInjectable from "../active-helm-repositories.injectable";
interface Dependencies {
publicRepositories: IAsyncComputed<HelmRepo[]>;
activeRepositories: IAsyncComputed<HelmRepo[]>;
selectRepository: (value: SingleValue<SelectOption<HelmRepo>>) => void;
}
const NonInjectedAddingOfPublicHelmRepository = observer(({
publicRepositories,
activeRepositories,
selectRepository,
}: Dependencies) => {
const dereferencesPublicRepositories = publicRepositories.value.get();
const dereferencesActiveRepositories = activeRepositories.value.get();
const valuesAreLoading = publicRepositories.pending.get() || activeRepositories.pending.get();
const repositoryOptions = dereferencesPublicRepositories.map(repository => ({
value: repository,
label: repository.name,
isSelected: !!dereferencesActiveRepositories.find(matches({ name: repository.name })),
}));
return (
<Select
id="selection-of-active-public-helm-repository"
placeholder="Repositories"
isLoading={valuesAreLoading}
isDisabled={valuesAreLoading}
options={repositoryOptions}
onChange={selectRepository}
value={dereferencesPublicRepositories}
formatOptionLabel={formatOptionLabel}
controlShouldRenderValue={false}
className="box grow"
themeName="lens"
/>
);
});
export const AddingOfPublicHelmRepository = withInjectables<Dependencies>(
NonInjectedAddingOfPublicHelmRepository,
{
getProps: (di) => ({
publicRepositories: di.inject(publicHelmRepositoriesInjectable),
activeRepositories: di.inject(activeHelmRepositoriesInjectable),
selectRepository: di.inject(selectHelmRepositoryInjectable),
}),
},
);
const formatOptionLabel = ({ value, isSelected }: SelectOption<HelmRepo>) => (
<div className="flex gaps">
<span>{value.name}</span>
{isSelected && (
<Icon
small
material="check"
className="box right" />
)}
</div>
);

View File

@ -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 { sortBy } from "lodash/fp";
import type { HelmRepo } from "../../../../../../../common/helm/helm-repo";
import { customRequestPromise } from "../../../../../../../common/request";
const callForPublicHelmRepositoriesInjectable = getInjectable({
id: "call-for-public-helm-repositories",
instantiate: () => async (): Promise<HelmRepo[]> => {
const res = await customRequestPromise({
uri: "https://github.com/lensapp/artifact-hub-repositories/releases/download/latest/repositories.json",
json: true,
resolveWithFullResponse: true,
timeout: 10000,
});
const repositories = res.body as HelmRepo[];
return sortBy(repo => repo.name, repositories);
},
causesSideEffects: true,
});
export default callForPublicHelmRepositoriesInjectable;

View File

@ -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 { asyncComputed } from "@ogre-tools/injectable-react";
import callForPublicHelmRepositoriesInjectable from "./call-for-public-helm-repositories.injectable";
const publicHelmRepositoriesInjectable = getInjectable({
id: "public-helm-repositories",
instantiate: (di) => {
const callForPublicHelmRepositories = di.inject(callForPublicHelmRepositoriesInjectable);
return asyncComputed(async () => {
return await callForPublicHelmRepositories();
}, []);
},
});
export default publicHelmRepositoriesInjectable;

View File

@ -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 addHelmRepositoryChannelInjectable from "../../../../../../../common/helm/add-helm-repository-channel.injectable";
import type { HelmRepo } from "../../../../../../../common/helm/helm-repo";
import { requestFromChannelInjectionToken } from "../../../../../../../common/utils/channel/request-from-channel-injection-token";
import activeHelmRepositoriesInjectable from "../../active-helm-repositories.injectable";
import showErrorNotificationInjectable from "../../../../../notifications/show-error-notification.injectable";
import showSuccessNotificationInjectable from "../../../../../notifications/show-success-notification.injectable";
const addHelmRepositoryInjectable = getInjectable({
id: "add-public-helm-repository",
instantiate: (di) => {
const requestFromChannel = di.inject(requestFromChannelInjectionToken);
const addHelmRepositoryChannel = di.inject(addHelmRepositoryChannelInjectable);
const activeHelmRepositories = di.inject(activeHelmRepositoriesInjectable);
const showErrorNotification = di.inject(showErrorNotificationInjectable);
const showSuccessNotification = di.inject(showSuccessNotificationInjectable);
return async (repository: HelmRepo) => {
const result = await requestFromChannel(
addHelmRepositoryChannel,
repository,
);
if (result.callWasSuccessful) {
showSuccessNotification(
`Helm repository ${repository.name} has been added.`,
);
activeHelmRepositories.invalidate();
} else {
showErrorNotification(result.error);
}
};
},
});
export default addHelmRepositoryInjectable;

View File

@ -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 addHelmRepositoryInjectable from "./add-helm-repository.injectable";
import type { SelectOption } from "../../../../../select";
import type { HelmRepo } from "../../../../../../../common/helm/helm-repo";
import type { SingleValue } from "react-select";
import removeHelmRepositoryInjectable from "../../remove-helm-repository.injectable";
const selectHelmRepositoryInjectable = getInjectable({
id: "select-helm-repository",
instantiate: (di) => {
const addHelmRepository = di.inject(addHelmRepositoryInjectable);
const removeHelmRepository = di.inject(removeHelmRepositoryInjectable);
return (selected: SingleValue<SelectOption<HelmRepo>>) => {
if (!selected) {
return;
}
if (!selected.isSelected) {
addHelmRepository(selected.value);
} else {
removeHelmRepository(selected.value);
}
};
},
});
export default selectHelmRepositoryInjectable;

View File

@ -0,0 +1,61 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import React from "react";
import { HelmRepositories } from "./helm-repositories";
import { AddingOfPublicHelmRepository } from "./adding-of-public-helm-repository/adding-of-public-helm-repository";
import { AddingOfCustomHelmRepositoryOpenButton } from "./adding-of-custom-helm-repository/adding-of-custom-helm-repository-open-button";
import { AddingOfCustomHelmRepositoryDialog } from "./adding-of-custom-helm-repository/adding-of-custom-helm-repository-dialog";
import { withInjectables } from "@ogre-tools/injectable-react";
import type { HelmRepositoriesErrorState } from "./helm-repositories-error-state.injectable";
import helmRepositoriesErrorStateInjectable from "./helm-repositories-error-state.injectable";
import type { IObservableValue } from "mobx";
import { observer } from "mobx-react";
import { Notice } from "../../../+extensions/notice";
interface Dependencies {
helmRepositoriesErrorState: IObservableValue<HelmRepositoriesErrorState>;
}
const NonInjectedHelmCharts = observer(
({ helmRepositoriesErrorState }: Dependencies) => {
const state = helmRepositoriesErrorState.get();
return (
<div>
{!state.controlsAreShown && (
<Notice>
<div className="flex-grow text-center">{state.errorMessage}</div>
</Notice>
)}
{state.controlsAreShown && (
<div data-testid="helm-controls">
<div className="flex gaps">
<AddingOfPublicHelmRepository />
<AddingOfCustomHelmRepositoryOpenButton />
</div>
<HelmRepositories />
<AddingOfCustomHelmRepositoryDialog />
</div>
)}
</div>
);
},
);
export const HelmCharts = withInjectables<Dependencies>(
NonInjectedHelmCharts,
{
getProps: (di) => ({
helmRepositoriesErrorState: di.inject(helmRepositoriesErrorStateInjectable),
}),
},
);

View File

@ -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 { observable } from "mobx";
export type HelmRepositoriesErrorState =
| { controlsAreShown: true }
| { controlsAreShown: false; errorMessage: string };
const helmRepositoriesErrorStateInjectable = getInjectable({
id: "helm-repositories-error-state",
instantiate: () =>
observable.box<HelmRepositoriesErrorState>({ controlsAreShown: true }),
});
export default helmRepositoriesErrorStateInjectable;

View File

@ -0,0 +1,68 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import styles from "./helm-charts.module.scss";
import React from "react";
import { observer } from "mobx-react";
import activeHelmRepositoriesInjectable from "./active-helm-repositories.injectable";
import type { IAsyncComputed } from "@ogre-tools/injectable-react";
import { withInjectables } from "@ogre-tools/injectable-react";
import { Spinner } from "../../../spinner";
import type { HelmRepo } from "../../../../../common/helm/helm-repo";
import { RemovableItem } from "../../removable-item";
import removeHelmRepositoryInjectable from "./remove-helm-repository.injectable";
interface Dependencies {
activeHelmRepositories: IAsyncComputed<HelmRepo[]>;
removeRepository: (repository: HelmRepo) => Promise<void>;
}
const NonInjectedActiveHelmRepositories = observer(({ activeHelmRepositories, removeRepository }: Dependencies) => {
if (activeHelmRepositories.pending.get()) {
return (
<div className={styles.repos}>
<div className="pt-5 relative">
<Spinner center data-testid="helm-repositories-are-loading" />
</div>
</div>
);
}
const repositories = activeHelmRepositories.value.get();
return (
<div className={styles.repos}>
{repositories.map((repository) => (
<RemovableItem
key={repository.name}
onRemove={() => removeRepository(repository)}
className="mt-3"
data-testid={`remove-helm-repository-${repository.name}`}
>
<div data-testid={`helm-repository-${repository.name}`} className={styles.repoName}>
{repository.name}
</div>
<div className={styles.repoUrl}>{repository.url}</div>
</RemovableItem>
))}
</div>
);
});
export const HelmRepositories = withInjectables<Dependencies>(
NonInjectedActiveHelmRepositories,
{
getProps: (di) => ({
activeHelmRepositories: di.inject(activeHelmRepositoriesInjectable),
removeRepository: di.inject(removeHelmRepositoryInjectable),
}),
},
);

View File

@ -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 type { HelmRepo } from "../../../../../common/helm/helm-repo";
import { requestFromChannelInjectionToken } from "../../../../../common/utils/channel/request-from-channel-injection-token";
import activeHelmRepositoriesInjectable from "./active-helm-repositories.injectable";
import removeHelmRepositoryChannelInjectable from "../../../../../common/helm/remove-helm-repository-channel.injectable";
const removePublicHelmRepositoryInjectable = getInjectable({
id: "remove-public-helm-repository",
instantiate: (di) => {
const requestFromChannel = di.inject(requestFromChannelInjectionToken);
const removeHelmRepositoryChannel = di.inject(removeHelmRepositoryChannelInjectable);
const activeHelmRepositories = di.inject(activeHelmRepositoriesInjectable);
return async (repository: HelmRepo) => {
await requestFromChannel(removeHelmRepositoryChannel, repository);
activeHelmRepositories.invalidate();
};
},
});
export default removePublicHelmRepositoryInjectable;

View File

@ -14,9 +14,10 @@ export interface RemovableItemProps extends DOMAttributes<any>{
icon?: string;
onRemove: () => void;
className?: string;
"data-testid"?: string;
}
export function RemovableItem({ icon, onRemove, children, className, ...rest }: RemovableItemProps) {
export function RemovableItem({ icon, onRemove, children, className, "data-testid": testId, ...rest }: RemovableItemProps) {
return (
<div className={cssNames(styles.item, "flex gaps align-center justify-space-between", className)} {...rest}>
{icon && (
@ -27,6 +28,7 @@ export function RemovableItem({ icon, onRemove, children, className, ...rest }:
material="delete"
onClick={onRemove}
tooltip="Remove"
data-testid={testId}
/>
</div>
);

View File

@ -0,0 +1,32 @@
/**
* 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 { AsyncInputValidationError, inputValidator } from "../input_validators";
import pathExistsInjectable from "../../../../common/fs/path-exists.injectable";
const isPathInjectable = getInjectable({
id: "is-path",
instantiate: (di) => {
const pathExists = di.inject(pathExistsInjectable);
return inputValidator<true>({
debounce: 100,
condition: ({ type }) => type === "text",
validate: async (value) => {
try {
await pathExists(value);
} catch {
throw new AsyncInputValidationError(
`${value} is not a valid file path`,
);
}
},
});
},
});
export default isPathInjectable;

View File

@ -0,0 +1,26 @@
/**
* 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 { NotificationMessage, Notification } from "./notifications.store";
import { NotificationStatus } from "./notifications.store";
import notificationsStoreInjectable from "./notifications-store.injectable";
const showErrorNotificationInjectable = getInjectable({
id: "show-error-notification",
instantiate: (di) => {
const notificationsStore = di.inject(notificationsStoreInjectable);
return (message: NotificationMessage, customOpts: Partial<Omit<Notification, "message">> = {}) =>
notificationsStore.add({
status: NotificationStatus.ERROR,
timeout: 5000,
message,
...customOpts,
});
},
});
export default showErrorNotificationInjectable;

View File

@ -0,0 +1,26 @@
/**
* 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 { NotificationMessage, Notification } from "./notifications.store";
import { NotificationStatus } from "./notifications.store";
import notificationsStoreInjectable from "./notifications-store.injectable";
const showSuccessNotificationInjectable = getInjectable({
id: "show-success-notification",
instantiate: (di) => {
const notificationsStore = di.inject(notificationsStoreInjectable);
return (message: NotificationMessage, customOpts: Partial<Omit<Notification, "message">> = {}) =>
notificationsStore.add({
status: NotificationStatus.OK,
timeout: 5000,
message,
...customOpts,
});
},
});
export default showSuccessNotificationInjectable;

View File

@ -233,7 +233,9 @@ class NonInjectedSelect<
Menu: ({ className, ...props }) => (
<WrappedMenu
{...props}
className={cssNames(menuClass, this.themeClass, className)}
className={cssNames(menuClass, this.themeClass, className, {
[`${inputId}-options`]: !!inputId,
})}
/>
),
}}

View File

@ -15,7 +15,7 @@ import { Observer } from "mobx-react";
import subscribeStoresInjectable from "../../kube-watch-api/subscribe-stores.injectable";
import allowedResourcesInjectable from "../../../common/cluster-store/allowed-resources.injectable";
import type { RenderResult } from "@testing-library/react";
import { fireEvent } from "@testing-library/react";
import { getByText, fireEvent } from "@testing-library/react";
import type { KubeResource } from "../../../common/rbac";
import { Sidebar } from "../layout/sidebar";
import type { DiContainer } from "@ogre-tools/injectable";
@ -52,6 +52,9 @@ import { getDiForUnitTesting as getMainDi } from "../../../main/getDiForUnitTest
import { overrideChannels } from "../../../test-utils/channel-fakes/override-channels";
import type { TrayMenuItem } from "../../../main/tray/tray-menu-item/tray-menu-item-injection-token";
import trayIconPathsInjectable from "../../../main/tray/tray-icon-path.injectable";
import assert from "assert";
import { openMenu } from "react-select-event";
import userEvent from "@testing-library/user-event";
type Callback = (dis: DiContainers) => void | Promise<void>;
@ -85,6 +88,11 @@ export interface ApplicationBuilder {
helmCharts: {
navigate: () => void;
};
select: {
openMenu: (id: string) => void;
selectOption: (menuId: string, labelText: string) => void;
};
}
interface DiContainers {
@ -433,6 +441,30 @@ export const getApplicationBuilder = () => {
return rendered;
},
select: {
openMenu: (menuId) => {
const selector = rendered.container.querySelector<HTMLElement>(
`#${menuId}`,
);
assert(selector);
openMenu(selector);
},
selectOption: (menuId, labelText) => {
const menuOptions = rendered.baseElement.querySelector<HTMLElement>(
`.${menuId}-options`,
);
assert(menuOptions);
const option = getByText(menuOptions, labelText);
userEvent.click(option);
},
},
};
return builder;

View File

@ -129,6 +129,8 @@ export interface WizardStepProps<D> extends WizardCommonProps<D> {
skip?: boolean; // don't render the step
scrollable?: boolean;
children?: React.ReactNode | React.ReactNode[];
testIdForNext?: string;
testIdForPrev?: string;
}
interface WizardStepState {
@ -214,7 +216,7 @@ export class WizardStep<D> extends React.Component<WizardStepProps<D>, WizardSte
step, isFirst, isLast, children,
loading, customButtons, disabledNext, scrollable,
hideNextBtn, hideBackBtn, beforeContent, afterContent, noValidate, skip, moreButtons,
waiting, className, contentClass, prevLabel, nextLabel,
waiting, className, contentClass, prevLabel, nextLabel, testIdForNext, testIdForPrev,
} = this.props;
if (skip) {
@ -242,6 +244,7 @@ export class WizardStep<D> extends React.Component<WizardStepProps<D>, WizardSte
label={prevLabel || (isFirst?.() ? "Cancel" : "Back")}
hidden={hideBackBtn}
onClick={this.prev}
data-testid={testIdForPrev}
/>
<Button
primary
@ -250,6 +253,7 @@ export class WizardStep<D> extends React.Component<WizardStepProps<D>, WizardSte
hidden={hideNextBtn}
waiting={waiting ?? this.state.waiting}
disabled={disabledNext}
data-testid={testIdForNext}
/>
</div>
)}

View File

@ -5,7 +5,12 @@
import glob from "glob";
import { memoize, noop } from "lodash/fp";
import { createContainer } from "@ogre-tools/injectable";
import type {
DiContainer,
Injectable } from "@ogre-tools/injectable";
import {
createContainer,
} from "@ogre-tools/injectable";
import { Environments, setLegacyGlobalDiForExtensionApi } from "../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api";
import requestFromChannelInjectable from "./utils/channel/request-from-channel.injectable";
import loggerInjectable from "../common/logger.injectable";
@ -45,6 +50,8 @@ import appVersionInjectable from "../common/get-configuration-file-model/app-ver
import provideInitialValuesForSyncBoxesInjectable from "./utils/sync-box/provide-initial-values-for-sync-boxes.injectable";
import requestAnimationFrameInjectable from "./components/animate/request-animation-frame.injectable";
import getRandomIdInjectable from "../common/utils/get-random-id.injectable";
import getFilePathsInjectable from "./components/+preferences/kubernetes/helm-charts/adding-of-custom-helm-repository/helm-file-input/get-file-paths.injectable";
import callForPublicHelmRepositoriesInjectable from "./components/+preferences/kubernetes/helm-charts/adding-of-public-helm-repository/public-helm-repositories/call-for-public-helm-repositories.injectable";
export const getDiForUnitTesting = (opts: { doGeneralOverrides?: boolean } = {}) => {
const {
@ -91,9 +98,11 @@ export const getDiForUnitTesting = (opts: { doGeneralOverrides?: boolean } = {})
on: () => {},
}) as unknown as IpcRenderer);
di.override(broadcastMessageInjectable, () => () => {
throw new Error("Tried to broadcast message over IPC without explicit override.");
});
overrideFunctionalInjectables(di, [
broadcastMessageInjectable,
getFilePathsInjectable,
callForPublicHelmRepositoriesInjectable,
]);
// eslint-disable-next-line unused-imports/no-unused-vars-ts
di.override(extensionsStoreInjectable, () => ({ isEnabled: ({ id, isBundled }) => false }) as ExtensionsStore);
@ -147,3 +156,11 @@ const getInjectableFilePaths = memoize(() => [
...glob.sync("../common/**/*.injectable.{ts,tsx}", { cwd: __dirname }),
...glob.sync("../extensions/**/*.injectable.{ts,tsx}", { cwd: __dirname }),
]);
const overrideFunctionalInjectables = (di: DiContainer, injectables: Injectable<any, any, any>[]) => {
injectables.forEach(injectable => {
di.override(injectable, () => () => {
throw new Error(`Tried to run "${injectable.id}" without explicit override.`);
});
});
};