diff --git a/integration/__tests__/app.tests.ts b/integration/__tests__/app.tests.ts index 5cde34afb5..492f02d1e6 100644 --- a/integration/__tests__/app.tests.ts +++ b/integration/__tests__/app.tests.ts @@ -1,6 +1,6 @@ /* Cluster tests are run if there is a pre-existing minikube cluster. Before running cluster tests the TEST_NAMESPACE - namespace is removed, if it exists, from the minikube cluster. Resources are created as part of the cluster tests in the + namespace is removed, if it exists, from the minikube cluster. Resources are created as part of the cluster tests in the TEST_NAMESPACE namespace. This is done to minimize destructive impact of the cluster tests on an existing minikube cluster and vice versa. */ @@ -8,8 +8,8 @@ import { Application } from "spectron" import * as util from "../helpers/utils" import { spawnSync } from "child_process" -const describeif = (condition : boolean) => condition ? describe : describe.skip -const itif = (condition : boolean) => condition ? it : it.skip +const describeif = (condition: boolean) => condition ? describe : describe.skip +const itif = (condition: boolean) => condition ? it : it.skip jest.setTimeout(60000) @@ -29,7 +29,7 @@ describe("Lens integration tests", () => { } const clickWhatsNew = async (app: Application) => { - await app.client.waitUntilTextExists("h1", "What's new") + await app.client.waitUntilTextExists("h1", "What's new?") await app.client.click("button.primary") await app.client.waitUntilTextExists("h1", "Welcome") } @@ -140,7 +140,7 @@ describe("Lens integration tests", () => { await addCluster() } } - + describe("cluster pages", () => { beforeAll(appStartAddCluster, 40000) @@ -150,8 +150,8 @@ describe("Lens integration tests", () => { return util.tearDown(app) } }) - - const tests : { + + const tests: { drawer?: string drawerId?: string pages: { @@ -160,232 +160,230 @@ describe("Lens integration tests", () => { expectedSelector: string, expectedText: string }[] - }[] = [ - { - drawer: "", - drawerId: "", - pages: [ { - name: "Cluster", - href: "cluster", - expectedSelector: "div.ClusterNoMetrics p", - expectedText: "Metrics are not available due" - }] + }[] = [{ + drawer: "", + drawerId: "", + pages: [{ + name: "Cluster", + href: "cluster", + expectedSelector: "div.ClusterNoMetrics p", + expectedText: "Metrics are not available due" + }] + }, + { + drawer: "", + drawerId: "", + pages: [{ + name: "Nodes", + href: "nodes", + expectedSelector: "h5.title", + expectedText: "Nodes" + }] + }, + { + drawer: "Workloads", + drawerId: "workloads", + pages: [{ + name: "Overview", + href: "workloads", + expectedSelector: "h5.box", + expectedText: "Overview" }, { - drawer: "", - drawerId: "", - pages: [ { - name: "Nodes", - href: "nodes", - expectedSelector: "h5.title", - expectedText: "Nodes" - }] + name: "Pods", + href: "pods", + expectedSelector: "h5.title", + expectedText: "Pods" }, { - drawer: "Workloads", - drawerId: "workloads", - pages: [ { - name: "Overview", - href: "workloads", - expectedSelector: "h5.box", - expectedText: "Overview" - }, - { - name: "Pods", - href: "pods", - expectedSelector: "h5.title", - expectedText: "Pods" - }, - { - name: "Deployments", - href: "deployments", - expectedSelector: "h5.title", - expectedText: "Deployments" - }, - { - name: "DaemonSets", - href: "daemonsets", - expectedSelector: "h5.title", - expectedText: "Daemon Sets" - }, - { - name: "StatefulSets", - href: "statefulsets", - expectedSelector: "h5.title", - expectedText: "Stateful Sets" - }, - { - name: "Jobs", - href: "jobs", - expectedSelector: "h5.title", - expectedText: "Jobs" - }, - { - name: "CronJobs", - href: "cronjobs", - expectedSelector: "h5.title", - expectedText: "Cron Jobs" - } ] + name: "Deployments", + href: "deployments", + expectedSelector: "h5.title", + expectedText: "Deployments" }, { - drawer: "Configuration", - drawerId: "config", - pages: [ { - name: "ConfigMaps", - href: "configmaps", - expectedSelector: "h5.title", - expectedText: "Config Maps" - }, - { - name: "Secrets", - href: "secrets", - expectedSelector: "h5.title", - expectedText: "Secrets" - }, - { - name: "Resource Quotas", - href: "resourcequotas", - expectedSelector: "h5.title", - expectedText: "Resource Quotas" - }, - { - name: "HPA", - href: "hpa", - expectedSelector: "h5.title", - expectedText: "Horizontal Pod Autoscalers" - }, - { - name: "Pod Disruption Budgets", - href: "poddisruptionbudgets", - expectedSelector: "h5.title", - expectedText: "Pod Disruption Budgets" - } ] + name: "DaemonSets", + href: "daemonsets", + expectedSelector: "h5.title", + expectedText: "Daemon Sets" }, { - drawer: "Network", - drawerId: "networks", - pages: [ { - name: "Services", - href: "services", - expectedSelector: "h5.title", - expectedText: "Services" - }, - { - name: "Endpoints", - href: "endpoints", - expectedSelector: "h5.title", - expectedText: "Endpoints" - }, - { - name: "Ingresses", - href: "ingresses", - expectedSelector: "h5.title", - expectedText: "Ingresses" - }, - { - name: "Network Policies", - href: "network-policies", - expectedSelector: "h5.title", - expectedText: "Network Policies" - } ] + name: "StatefulSets", + href: "statefulsets", + expectedSelector: "h5.title", + expectedText: "Stateful Sets" }, { - drawer: "Storage", - drawerId: "storage", - pages: [ { - name: "Persistent Volume Claims", - href: "persistent-volume-claims", - expectedSelector: "h5.title", - expectedText: "Persistent Volume Claims" - }, - { - name: "Persistent Volumes", - href: "persistent-volumes", - expectedSelector: "h5.title", - expectedText: "Persistent Volumes" - }, - { - name: "Storage Classes", - href: "storage-classes", - expectedSelector: "h5.title", - expectedText: "Storage Classes" - } ] + name: "Jobs", + href: "jobs", + expectedSelector: "h5.title", + expectedText: "Jobs" }, { - drawer: "", - drawerId: "", - pages: [ { - name: "Namespaces", - href: "namespaces", - expectedSelector: "h5.title", - expectedText: "Namespaces" - }] + name: "CronJobs", + href: "cronjobs", + expectedSelector: "h5.title", + expectedText: "Cron Jobs" + }] + }, + { + drawer: "Configuration", + drawerId: "config", + pages: [{ + name: "ConfigMaps", + href: "configmaps", + expectedSelector: "h5.title", + expectedText: "Config Maps" }, { - drawer: "", - drawerId: "", - pages: [ { - name: "Events", - href: "events", - expectedSelector: "h5.title", - expectedText: "Events" - }] + name: "Secrets", + href: "secrets", + expectedSelector: "h5.title", + expectedText: "Secrets" }, { - drawer: "Apps", - drawerId: "apps", - pages: [ { - name: "Charts", - href: "apps/charts", - expectedSelector: "div.HelmCharts input", - expectedText: "" - }, - { - name: "Releases", - href: "apps/releases", - expectedSelector: "h5.title", - expectedText: "Releases" - } ] + name: "Resource Quotas", + href: "resourcequotas", + expectedSelector: "h5.title", + expectedText: "Resource Quotas" }, { - drawer: "Access Control", - drawerId: "users", - pages: [ { - name: "Service Accounts", - href: "service-accounts", - expectedSelector: "h5.title", - expectedText: "Service Accounts" - }, - { - name: "Role Bindings", - href: "role-bindings", - expectedSelector: "h5.title", - expectedText: "Role Bindings" - }, - { - name: "Roles", - href: "roles", - expectedSelector: "h5.title", - expectedText: "Roles" - }, - { - name: "Pod Security Policies", - href: "pod-security-policies", - expectedSelector: "h5.title", - expectedText: "Pod Security Policies" - } ] + name: "HPA", + href: "hpa", + expectedSelector: "h5.title", + expectedText: "Horizontal Pod Autoscalers" }, { - drawer: "Custom Resources", - drawerId: "custom-resources", - pages: [ { - name: "Definitions", - href: "crd/definitions", - expectedSelector: "h5.title", - expectedText: "Custom Resources" - } ] + name: "Pod Disruption Budgets", + href: "poddisruptionbudgets", + expectedSelector: "h5.title", + expectedText: "Pod Disruption Budgets" + }] + }, + { + drawer: "Network", + drawerId: "networks", + pages: [{ + name: "Services", + href: "services", + expectedSelector: "h5.title", + expectedText: "Services" }, - ]; + { + name: "Endpoints", + href: "endpoints", + expectedSelector: "h5.title", + expectedText: "Endpoints" + }, + { + name: "Ingresses", + href: "ingresses", + expectedSelector: "h5.title", + expectedText: "Ingresses" + }, + { + name: "Network Policies", + href: "network-policies", + expectedSelector: "h5.title", + expectedText: "Network Policies" + }] + }, + { + drawer: "Storage", + drawerId: "storage", + pages: [{ + name: "Persistent Volume Claims", + href: "persistent-volume-claims", + expectedSelector: "h5.title", + expectedText: "Persistent Volume Claims" + }, + { + name: "Persistent Volumes", + href: "persistent-volumes", + expectedSelector: "h5.title", + expectedText: "Persistent Volumes" + }, + { + name: "Storage Classes", + href: "storage-classes", + expectedSelector: "h5.title", + expectedText: "Storage Classes" + }] + }, + { + drawer: "", + drawerId: "", + pages: [{ + name: "Namespaces", + href: "namespaces", + expectedSelector: "h5.title", + expectedText: "Namespaces" + }] + }, + { + drawer: "", + drawerId: "", + pages: [{ + name: "Events", + href: "events", + expectedSelector: "h5.title", + expectedText: "Events" + }] + }, + { + drawer: "Apps", + drawerId: "apps", + pages: [{ + name: "Charts", + href: "apps/charts", + expectedSelector: "div.HelmCharts input", + expectedText: "" + }, + { + name: "Releases", + href: "apps/releases", + expectedSelector: "h5.title", + expectedText: "Releases" + }] + }, + { + drawer: "Access Control", + drawerId: "users", + pages: [{ + name: "Service Accounts", + href: "service-accounts", + expectedSelector: "h5.title", + expectedText: "Service Accounts" + }, + { + name: "Role Bindings", + href: "role-bindings", + expectedSelector: "h5.title", + expectedText: "Role Bindings" + }, + { + name: "Roles", + href: "roles", + expectedSelector: "h5.title", + expectedText: "Roles" + }, + { + name: "Pod Security Policies", + href: "pod-security-policies", + expectedSelector: "h5.title", + expectedText: "Pod Security Policies" + }] + }, + { + drawer: "Custom Resources", + drawerId: "custom-resources", + pages: [{ + name: "Definitions", + href: "crd/definitions", + expectedSelector: "h5.title", + expectedText: "Custom Resources" + }] + }]; tests.forEach(({ drawer = "", drawerId = "", pages }) => { if (drawer !== "") { it(`shows ${drawer} drawer`, async () => { @@ -393,8 +391,8 @@ describe("Lens integration tests", () => { await app.client.click(`.sidebar-nav #${drawerId} span.link-text`) await app.client.waitUntilTextExists(`a[href="/${pages[0].href}"]`, pages[0].name) }) - } - pages.forEach(({name, href, expectedSelector, expectedText}) => { + } + pages.forEach(({ name, href, expectedSelector, expectedText }) => { it(`shows ${drawer}->${name} page`, async () => { expect(clusterAdded).toBe(true) await app.client.click(`a[href="/${href}"]`) @@ -409,7 +407,7 @@ describe("Lens integration tests", () => { await expect(app.client.waitUntilTextExists(`a[href="/${pages[0].href}"]`, pages[0].name, 100)).rejects.toThrow() }) } - }) + }) }) describe("cluster operations", () => { @@ -420,7 +418,7 @@ describe("Lens integration tests", () => { return util.tearDown(app) } }) - + it('shows default namespace', async () => { expect(clusterAdded).toBe(true) await app.client.click('a[href="/namespaces"]') @@ -468,6 +466,6 @@ describe("Lens integration tests", () => { await app.client.click(".name=nginx-create-pod-test") await app.client.waitUntilTextExists("div.drawer-title-text", "Pod: nginx-create-pod-test") }) - }) - }) + }) + }) }) diff --git a/integration/helpers/utils.ts b/integration/helpers/utils.ts index a50b13e023..55395f84e3 100644 --- a/integration/helpers/utils.ts +++ b/integration/helpers/utils.ts @@ -12,7 +12,7 @@ export function setup(): Application { args: [], path: AppPaths[process.platform], startTimeout: 30000, - waitTimeout: 30000, + waitTimeout: 60000, chromeDriverArgs: ['remote-debugging-port=9222'], env: { CICD: "true" diff --git a/package.json b/package.json index 5b31e6e534..ea65b9d2f0 100644 --- a/package.json +++ b/package.json @@ -289,6 +289,7 @@ "identity-obj-proxy": "^3.0.0", "include-media": "^1.4.9", "jest": "^26.0.1", + "jest-mock-extended": "^1.0.10", "make-plural": "^6.2.1", "material-design-icons": "^3.0.1", "mini-css-extract-plugin": "^0.9.0", diff --git a/src/main/__test__/cluster.test.ts b/src/main/__test__/cluster.test.ts new file mode 100644 index 0000000000..52302c7064 --- /dev/null +++ b/src/main/__test__/cluster.test.ts @@ -0,0 +1,165 @@ +const logger = { + silly: jest.fn(), + debug: jest.fn(), + log: jest.fn(), + info: jest.fn(), + error: jest.fn(), + crit: jest.fn(), +}; + +jest.mock("winston", () => ({ + format: { + colorize: jest.fn(), + combine: jest.fn(), + simple: jest.fn(), + label: jest.fn(), + timestamp: jest.fn(), + printf: jest.fn() + }, + createLogger: jest.fn().mockReturnValue(logger), + transports: { + Console: jest.fn(), + File: jest.fn(), + } +})) + + +jest.mock("../../common/ipc") +jest.mock("../context-handler") +jest.mock("request") +jest.mock("request-promise-native") + +import { Console } from "console"; +import mockFs from "mock-fs"; +import { workspaceStore } from "../../common/workspace-store"; +import { Cluster } from "../cluster" +import { ContextHandler } from "../context-handler"; +import { getFreePort } from "../port"; +import { V1ResourceAttributes } from "@kubernetes/client-node"; +import { apiResources } from "../../common/rbac"; +import request from "request-promise-native" + +const mockedRequest = request as jest.MockedFunction + +console = new Console(process.stdout, process.stderr) // fix mockFS + +describe("create clusters", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + beforeEach(() => { + const mockOpts = { + "minikube-config.yml": JSON.stringify({ + apiVersion: "v1", + clusters: [{ + name: "minikube", + cluster: { + server: "https://192.168.64.3:8443", + }, + }], + contexts: [{ + context: { + cluster: "minikube", + user: "minikube", + }, + name: "minikube", + }], + users: [{ + name: "minikube", + }], + kind: "Config", + preferences: {}, + }) + } + mockFs(mockOpts) + }) + + afterEach(() => { + mockFs.restore() + }) + + it("should be able to create a cluster from a cluster model and apiURL should be decoded", () => { + const c = new Cluster({ + id: "foo", + contextName: "minikube", + kubeConfigPath: "minikube-config.yml", + workspace: workspaceStore.currentWorkspaceId + }) + expect(c.apiUrl).toBe("https://192.168.64.3:8443") + }) + + it("init should not throw if everything is in order", async () => { + const c = new Cluster({ + id: "foo", + contextName: "minikube", + kubeConfigPath: "minikube-config.yml", + workspace: workspaceStore.currentWorkspaceId + }) + await c.init(await getFreePort()) + expect(logger.info).toBeCalledWith(expect.stringContaining("init success"), { + id: "foo", + apiUrl: "https://192.168.64.3:8443", + context: "minikube", + }) + }) + + it("activating cluster should try to connect to cluster and do a refresh", async () => { + const port = await getFreePort() + jest.spyOn(ContextHandler.prototype, "ensureServer"); + + const mockListNSs = jest.fn() + const mockKC = { + makeApiClient() { + return { + listNamespace: mockListNSs, + } + } + } + + jest.spyOn(Cluster.prototype, "canI") + .mockImplementationOnce((attr: V1ResourceAttributes): Promise => { + expect(attr.namespace).toBe("default") + expect(attr.resource).toBe("pods") + expect(attr.verb).toBe("list") + return Promise.resolve(true) + }) + .mockImplementation((attr: V1ResourceAttributes): Promise => { + expect(attr.namespace).toBe("default") + expect(attr.verb).toBe("list") + return Promise.resolve(true) + }) + jest.spyOn(Cluster.prototype, "getProxyKubeconfig").mockReturnValue(mockKC as any) + mockListNSs.mockImplementationOnce(() => ({ + body: { + items: [{ + metadata: { + name: "default", + } + }] + } + })) + + mockedRequest.mockImplementationOnce(((uri: any, _options: any) => { + expect(uri).toBe(`http://localhost:${port}/api-kube/version`) + return Promise.resolve({ gitVersion: "1.2.3" }) + }) as any) + + const c = new Cluster({ + id: "foo", + contextName: "minikube", + kubeConfigPath: "minikube-config.yml", + workspace: workspaceStore.currentWorkspaceId + }) + await c.init(port) + await c.activate() + + expect(ContextHandler.prototype.ensureServer).toBeCalled() + expect(mockedRequest).toBeCalled() + expect(c.accessible).toBe(true) + expect(c.allowedNamespaces.length).toBe(1) + expect(c.allowedResources.length).toBe(apiResources.length) + + jest.resetAllMocks() + }) +}) diff --git a/src/main/__test__/kube-auth-proxy.test.ts b/src/main/__test__/kube-auth-proxy.test.ts new file mode 100644 index 0000000000..48f430b7c5 --- /dev/null +++ b/src/main/__test__/kube-auth-proxy.test.ts @@ -0,0 +1,130 @@ +const logger = { + silly: jest.fn(), + debug: jest.fn(), + log: jest.fn(), + info: jest.fn(), + error: jest.fn(), + crit: jest.fn(), +}; + +jest.mock("winston", () => ({ + format: { + colorize: jest.fn(), + combine: jest.fn(), + simple: jest.fn(), + label: jest.fn(), + timestamp: jest.fn(), + printf: jest.fn() + }, + createLogger: jest.fn().mockReturnValue(logger), + transports: { + Console: jest.fn(), + File: jest.fn(), + } +})) + +jest.mock("../../common/ipc") +jest.mock("child_process") +jest.mock("tcp-port-used") + +import { Cluster } from "../cluster" +import { KubeAuthProxy } from "../kube-auth-proxy" +import { getFreePort } from "../port" +import { broadcastIpc } from "../../common/ipc" +import { ChildProcess, spawn, SpawnOptions } from "child_process" +import { Kubectl } from "../kubectl" +import { mock, MockProxy } from 'jest-mock-extended'; +import { waitUntilUsed } from 'tcp-port-used'; +import { Readable } from "stream" + +const mockBroadcastIpc = broadcastIpc as jest.MockedFunction +const mockSpawn = spawn as jest.MockedFunction +const mockWaitUntilUsed = waitUntilUsed as jest.MockedFunction + +describe("kube auth proxy tests", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("calling exit multiple times shouldn't throw", async () => { + const port = await getFreePort() + const kap = new KubeAuthProxy(new Cluster({ id: "foobar", kubeConfigPath: "fake-path.yml" }), port, {}) + kap.exit() + kap.exit() + kap.exit() + }) + + describe("spawn tests", () => { + let port: number + let mockedCP: MockProxy + let listeners: Record void> + + beforeEach(async () => { + port = await getFreePort() + mockedCP = mock() + listeners = {} + + jest.spyOn(Kubectl.prototype, "checkBinary").mockReturnValueOnce(Promise.resolve(true)) + jest.spyOn(Kubectl.prototype, "ensureKubectl").mockReturnValueOnce(Promise.resolve(false)) + mockedCP.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): ChildProcess => { + listeners[event] = listener + return mockedCP + }) + mockedCP.stderr = mock() + mockedCP.stderr.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { + listeners[`stderr/${event}`] = listener + return mockedCP.stderr + }) + mockedCP.stdout = mock() + mockedCP.stdout.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { + listeners[`stdout/${event}`] = listener + return mockedCP.stdout + }) + mockSpawn.mockImplementationOnce((command: string, args: readonly string[], options: SpawnOptions): ChildProcess => { + expect(command).toBe(Kubectl.bundledKubectlPath) + return mockedCP + }) + mockWaitUntilUsed.mockReturnValueOnce(Promise.resolve()) + }) + + it("should call spawn and broadcast errors", async () => { + const kap = new KubeAuthProxy(new Cluster({ id: "foobar", kubeConfigPath: "fake-path.yml" }), port, {}) + await kap.run() + listeners["error"]({ message: "foobarbat" }) + + expect(mockBroadcastIpc).toBeCalledWith({ channel: "kube-auth:foobar", args: [{ data: "foobarbat", error: true }] }) + }) + + it("should call spawn and broadcast exit", async () => { + const kap = new KubeAuthProxy(new Cluster({ id: "foobar", kubeConfigPath: "fake-path.yml" }), port, {}) + await kap.run() + listeners["exit"](0) + + expect(mockBroadcastIpc).toBeCalledWith({ channel: "kube-auth:foobar", args: [{ data: "proxy exited with code: 0", error: false }] }) + }) + + it("should call spawn and broadcast errors from stderr", async () => { + const kap = new KubeAuthProxy(new Cluster({ id: "foobar", kubeConfigPath: "fake-path.yml" }), port, {}) + await kap.run() + listeners["stderr/data"]("an error") + + expect(mockBroadcastIpc).toBeCalledWith({ channel: "kube-auth:foobar", args: [{ data: "an error", error: true }] }) + }) + + it("should call spawn and broadcast stdout serving info", async () => { + const kap = new KubeAuthProxy(new Cluster({ id: "foobar", kubeConfigPath: "fake-path.yml" }), port, {}) + await kap.run() + listeners["stdout/data"]("Starting to serve on") + + expect(mockBroadcastIpc).toBeCalledWith({ channel: "kube-auth:foobar", args: [{ data: "Authentication proxy started\n" }] }) + }) + + it("should call spawn and broadcast stdout other info", async () => { + const kap = new KubeAuthProxy(new Cluster({ id: "foobar", kubeConfigPath: "fake-path.yml" }), port, {}) + await kap.run() + listeners["stdout/data"]("some info") + + expect(mockBroadcastIpc).toBeCalledWith({ channel: "kube-auth:foobar", args: [{ data: "some info" }] }) + }) + }) +}) diff --git a/src/main/__test__/kubeconfig-manager.test.ts b/src/main/__test__/kubeconfig-manager.test.ts new file mode 100644 index 0000000000..a0a4060111 --- /dev/null +++ b/src/main/__test__/kubeconfig-manager.test.ts @@ -0,0 +1,112 @@ +const logger = { + silly: jest.fn(), + debug: jest.fn(), + log: jest.fn(), + info: jest.fn(), + error: jest.fn(), + crit: jest.fn(), +}; + +jest.mock("winston", () => ({ + format: { + colorize: jest.fn(), + combine: jest.fn(), + simple: jest.fn(), + label: jest.fn(), + timestamp: jest.fn(), + printf: jest.fn() + }, + createLogger: jest.fn().mockReturnValue(logger), + transports: { + Console: jest.fn(), + File: jest.fn(), + } +})) + +import { KubeconfigManager } from "../kubeconfig-manager" +import mockFs from "mock-fs" +import { Cluster } from "../cluster"; +import { workspaceStore } from "../../common/workspace-store"; +import { ContextHandler } from "../context-handler"; +import { getFreePort } from "../port"; +import fse from "fs-extra" +import { loadYaml } from "@kubernetes/client-node"; +import { Console } from "console"; + +console = new Console(process.stdout, process.stderr) // fix mockFS + +describe("kubeconfig manager tests", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + beforeEach(() => { + const mockOpts = { + "minikube-config.yml": JSON.stringify({ + apiVersion: "v1", + clusters: [{ + name: "minikube", + cluster: { + server: "https://192.168.64.3:8443", + }, + }], + contexts: [{ + context: { + cluster: "minikube", + user: "minikube", + }, + name: "minikube", + }], + users: [{ + name: "minikube", + }], + kind: "Config", + preferences: {}, + }) + } + mockFs(mockOpts) + }) + + afterEach(() => { + mockFs.restore() + }) + + it("should create 'temp' kube config with proxy", async () => { + const cluster = new Cluster({ + id: "foo", + contextName: "minikube", + kubeConfigPath: "minikube-config.yml", + workspace: workspaceStore.currentWorkspaceId + }) + const contextHandler = new ContextHandler(cluster) + const port = await getFreePort() + const kubeConfManager = await KubeconfigManager.create(cluster, contextHandler, port) + + expect(logger.error).not.toBeCalled() + expect(kubeConfManager.getPath()).toBe("tmp/kubeconfig-foo") + const file = await fse.readFile(kubeConfManager.getPath()) + const yml = loadYaml(file.toString()) + expect(yml["current-context"]).toBe("minikube") + expect(yml["clusters"][0]["cluster"]["server"]).toBe(`http://127.0.0.1:${port}/foo`) + expect(yml["users"][0]["name"]).toBe("proxy") + }) + + it("should remove 'temp' kube config on unlink and remove reference from inside class", async () => { + const cluster = new Cluster({ + id: "foo", + contextName: "minikube", + kubeConfigPath: "minikube-config.yml", + workspace: workspaceStore.currentWorkspaceId + }) + const contextHandler = new ContextHandler(cluster) + const port = await getFreePort() + const kubeConfManager = await KubeconfigManager.create(cluster, contextHandler, port) + + const configPath = kubeConfManager.getPath() + expect(await fse.pathExists(configPath)).toBe(true) + await kubeConfManager.unlink() + expect(await fse.pathExists(configPath)).toBe(false) + await kubeConfManager.unlink() // doesn't throw + expect(kubeConfManager.getPath()).toBeUndefined() + }) +}) diff --git a/src/main/cluster.ts b/src/main/cluster.ts index 9c4afd71d0..f9b3c53ddc 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -93,7 +93,7 @@ export class Cluster implements ClusterModel { async init(port: number) { try { this.contextHandler = new ContextHandler(this); - this.kubeconfigManager = new KubeconfigManager(this, this.contextHandler, port); + this.kubeconfigManager = await KubeconfigManager.create(this, this.contextHandler, port); this.kubeProxyUrl = `http://localhost:${port}${apiKubePrefix}`; this.initialized = true; logger.info(`[CLUSTER]: "${this.contextName}" init success`, { diff --git a/src/main/kubeconfig-manager.ts b/src/main/kubeconfig-manager.ts index 59a47c3fbc..fc84d00ddb 100644 --- a/src/main/kubeconfig-manager.ts +++ b/src/main/kubeconfig-manager.ts @@ -11,8 +11,12 @@ export class KubeconfigManager { protected configDir = app.getPath("temp") protected tempFile: string; - constructor(protected cluster: Cluster, protected contextHandler: ContextHandler, protected port: number) { - this.init(); + private constructor(protected cluster: Cluster, protected contextHandler: ContextHandler, protected port: number) { } + + static async create(cluster: Cluster, contextHandler: ContextHandler, port: number) { + const kcm = new KubeconfigManager(cluster, contextHandler, port) + await kcm.init() + return kcm } protected async init() { @@ -72,8 +76,13 @@ export class KubeconfigManager { return tempFile; } - unlink() { + async unlink() { + if (!this.tempFile) { + return + } + logger.info('Deleting temporary kubeconfig: ' + this.tempFile) - fs.unlinkSync(this.tempFile) + await fs.unlink(this.tempFile) + this.tempFile = undefined } } diff --git a/yarn.lock b/yarn.lock index f64e36b703..1c92dd0978 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7080,6 +7080,13 @@ jest-message-util@^26.0.1: slash "^3.0.0" stack-utils "^2.0.2" +jest-mock-extended@^1.0.10: + version "1.0.10" + resolved "https://registry.yarnpkg.com/jest-mock-extended/-/jest-mock-extended-1.0.10.tgz#a4b1f5b0bb1121acf7c58cd5423d04c473532702" + integrity sha512-R2wKiOgEUPoHZ2kLsAQeQP2IfVEgo3oQqWLSXKdMXK06t3UHkQirA2Xnsdqg/pX6KPWTsdnrzE2ig6nqNjdgVw== + dependencies: + ts-essentials "^4.0.0" + jest-mock@^26.0.1: version "26.0.1" resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-26.0.1.tgz#7fd1517ed4955397cf1620a771dc2d61fad8fd40" @@ -11382,6 +11389,11 @@ truncate-utf8-bytes@^1.0.0: dependencies: utf8-byte-length "^1.0.1" +ts-essentials@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/ts-essentials/-/ts-essentials-4.0.0.tgz#506c42b270bbd0465574b90416533175b09205ab" + integrity sha512-uQJX+SRY9mtbKU+g9kl5Fi7AEMofPCvHfJkQlaygpPmHPZrtgaBqbWFOYyiA47RhnSwwnXdepUJrgqUYxoUyhQ== + ts-jest@^26.1.0: version "26.3.0" resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-26.3.0.tgz#6b2845045347dce394f069bb59358253bc1338a9"