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 cc9debddbe..e84e3e69b3 100644 --- a/package.json +++ b/package.json @@ -181,6 +181,7 @@ "@types/tar": "^4.0.3", "array-move": "^3.0.0", "chalk": "^4.1.0", + "command-exists": "1.2.9", "conf": "^7.0.1", "crypto-js": "^4.0.0", "electron-updater": "^4.3.1", @@ -298,6 +299,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/common/cluster-ipc.ts b/src/common/cluster-ipc.ts index 6ab46c1489..0ff5f41734 100644 --- a/src/common/cluster-ipc.ts +++ b/src/common/cluster-ipc.ts @@ -5,11 +5,10 @@ import { tracker } from "./tracker"; export const clusterIpc = { activate: createIpcChannel({ channel: "cluster:activate", - handle: (clusterId: ClusterId, frameId?: number) => { + handle: (clusterId: ClusterId, force = false) => { const cluster = clusterStore.getById(clusterId); if (cluster) { - if (frameId) cluster.frameId = frameId; // save cluster's webFrame.routingId to be able to send push-updates - return cluster.activate(); + return cluster.activate(force); } }, }), diff --git a/src/common/custom-errors.ts b/src/common/custom-errors.ts new file mode 100644 index 0000000000..3c7750487a --- /dev/null +++ b/src/common/custom-errors.ts @@ -0,0 +1,12 @@ +export class ExecValidationNotFoundError extends Error { + constructor(execPath: string, isAbsolute: boolean) { + super(`User Exec command "${execPath}" not found on host.`); + let message = `User Exec command "${execPath}" not found on host.`; + if (!isAbsolute) { + message += ` Please ensure binary is found in PATH or use absolute path to binary in Kubeconfig`; + } + this.message = message; + this.name = this.constructor.name; + Error.captureStackTrace(this, this.constructor); + } +} \ No newline at end of file diff --git a/src/common/kube-helpers.ts b/src/common/kube-helpers.ts index 18f472243b..8f517ba555 100644 --- a/src/common/kube-helpers.ts +++ b/src/common/kube-helpers.ts @@ -4,6 +4,8 @@ import path from "path" import os from "os" import yaml from "js-yaml" import logger from "../main/logger"; +import commandExists from "command-exists"; +import { ExecValidationNotFoundError } from "./custom-errors"; export const kubeConfigDefaultPath = path.join(os.homedir(), '.kube', 'config'); @@ -140,3 +142,28 @@ export function getNodeWarningConditions(node: V1Node) { c.status.toLowerCase() === "true" && c.type !== "Ready" && c.type !== "HostUpgrades" ) } + +/** + * Validates kubeconfig supplied in the add clusters screen. At present this will just validate + * the User struct, specifically the command passed to the exec substructure. + */ +export function validateKubeConfig (config: KubeConfig) { + // we only receive a single context, cluster & user object here so lets validate them as this + // will be called when we add a new cluster to Lens + logger.debug(`validateKubeConfig: validating kubeconfig - ${JSON.stringify(config)}`); + + // Validate the User Object + const user = config.getCurrentUser(); + if (user.exec) { + const execCommand = user.exec["command"]; + // check if the command is absolute or not + const isAbsolute = path.isAbsolute(execCommand); + // validate the exec struct in the user object, start with the command field + logger.debug(`validateKubeConfig: validating user exec command - ${JSON.stringify(execCommand)}`); + + if (!commandExists.sync(execCommand)) { + logger.debug(`validateKubeConfig: exec command ${String(execCommand)} in kubeconfig ${config.currentContext} not found`); + throw new ExecValidationNotFoundError(execCommand, isAbsolute); + } + } +} \ No newline at end of file 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..3b9e89ef86 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -46,6 +46,7 @@ export class Cluster implements ClusterModel { public contextHandler: ContextHandler; protected kubeconfigManager: KubeconfigManager; protected eventDisposers: Function[] = []; + protected activated = false; whenInitialized = when(() => this.initialized); whenReady = when(() => this.ready); @@ -93,7 +94,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`, { @@ -126,7 +127,10 @@ export class Cluster implements ClusterModel { } @action - async activate() { + async activate(force = false ) { + if (this.activated && !force) { + return this.pushState(); + } logger.info(`[CLUSTER]: activate`, this.getMeta()); await this.whenInitialized; if (!this.eventDisposers.length) { @@ -142,6 +146,7 @@ export class Cluster implements ClusterModel { this.kubeCtl = new Kubectl(this.version) this.kubeCtl.ensureKubectl() // download kubectl in background, so it's not blocking dashboard } + this.activated = true return this.pushState(); } @@ -162,6 +167,7 @@ export class Cluster implements ClusterModel { this.online = false; this.accessible = false; this.ready = false; + this.activated = false; this.pushState(); } @@ -184,9 +190,7 @@ export class Cluster implements ClusterModel { this.refreshEvents(), this.refreshAllowedResources(), ]); - if (!this.ready) { - this.ready = true - } + this.ready = true } this.pushState(); } 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/src/renderer/api/endpoints/pods.api.ts b/src/renderer/api/endpoints/pods.api.ts index c1394ab6db..e1545f09c7 100644 --- a/src/renderer/api/endpoints/pods.api.ts +++ b/src/renderer/api/endpoints/pods.api.ts @@ -47,6 +47,7 @@ export interface IPodLogsQuery { tailLines?: number; timestamps?: boolean; sinceTime?: string; // Date.toISOString()-format + follow?: boolean; } export enum PodStatus { diff --git a/src/renderer/components/+add-cluster/add-cluster.tsx b/src/renderer/components/+add-cluster/add-cluster.tsx index 2e483a718d..4b34d25f2e 100644 --- a/src/renderer/components/+add-cluster/add-cluster.tsx +++ b/src/renderer/components/+add-cluster/add-cluster.tsx @@ -13,7 +13,7 @@ import { AceEditor } from "../ace-editor"; import { Button } from "../button"; import { Icon } from "../icon"; import { WizardLayout } from "../layout/wizard-layout"; -import { kubeConfigDefaultPath, loadConfig, splitConfig, validateConfig } from "../../../common/kube-helpers"; +import { kubeConfigDefaultPath, loadConfig, splitConfig, validateConfig, validateKubeConfig } from "../../../common/kube-helpers"; import { ClusterModel, ClusterStore, clusterStore } from "../../../common/cluster-store"; import { workspaceStore } from "../../../common/workspace-store"; import { v4 as uuid } from "uuid" @@ -23,6 +23,7 @@ import { clusterViewURL } from "../cluster-manager/cluster-view.route"; import { cssNames } from "../../utils"; import { Notifications } from "../notifications"; import { Tab, Tabs } from "../tabs"; +import { ExecValidationNotFoundError } from "../../../common/custom-errors"; enum KubeConfigSourceTab { FILE = "file", @@ -120,6 +121,9 @@ export class AddCluster extends React.Component { @action addClusters = () => { + const configValidationErrors:string[] = []; + let newClusters: ClusterModel[] = []; + try { if (!this.selectedContexts.length) { this.error = Please select at least one cluster context @@ -128,7 +132,21 @@ export class AddCluster extends React.Component { this.error = "" this.isWaiting = true - const newClusters: ClusterModel[] = this.selectedContexts.map(context => { + newClusters = this.selectedContexts.filter(context => { + try { + const kubeConfig = this.kubeContexts.get(context); + validateKubeConfig(kubeConfig); + return true; + } catch (err) { + this.error = String(err.message) + if (err instanceof ExecValidationNotFoundError ) { + Notifications.error(Error while adding cluster(s): {this.error}); + return false; + } else { + throw new Error(err); + } + } + }).map(context => { const clusterId = uuid(); const kubeConfig = this.kubeContexts.get(context); const kubeConfigPath = this.sourceTab === KubeConfigSourceTab.FILE @@ -144,7 +162,8 @@ export class AddCluster extends React.Component { httpsProxy: this.proxyServer || undefined, }, } - }); + }) + clusterStore.addCluster(...newClusters); @@ -152,9 +171,9 @@ export class AddCluster extends React.Component { const clusterId = newClusters[0].id; navigate(clusterViewURL({ params: { clusterId } })); } else { - Notifications.ok( - Successfully imported {newClusters.length} cluster(s) - ); + if (newClusters.length > 1) {Notifications.ok( + Successfully imported {newClusters.length} cluster(s)); + } } } catch (err) { this.error = String(err); diff --git a/src/renderer/components/+cluster-settings/cluster-settings.tsx b/src/renderer/components/+cluster-settings/cluster-settings.tsx index 939cc61879..a373a82831 100644 --- a/src/renderer/components/+cluster-settings/cluster-settings.tsx +++ b/src/renderer/components/+cluster-settings/cluster-settings.tsx @@ -53,9 +53,11 @@ export class ClusterSettings extends React.Component { } } - refreshCluster = (cluster: Cluster) => { - if (!cluster) return; - clusterIpc.refresh.invokeFromRenderer(cluster.id); + refreshCluster = async () => { + if(this.cluster) { + await clusterIpc.activate.invokeFromRenderer(this.cluster.id); + clusterIpc.refresh.invokeFromRenderer(this.cluster.id); + } } close() { diff --git a/src/renderer/components/+workloads-pods/pod-logs-dialog.scss b/src/renderer/components/+workloads-pods/pod-logs-dialog.scss deleted file mode 100644 index 0c8845c78f..0000000000 --- a/src/renderer/components/+workloads-pods/pod-logs-dialog.scss +++ /dev/null @@ -1,110 +0,0 @@ -.PodLogsDialog { - --log-line-height: 16px; - - .Wizard { - width: 90vw; - max-height: none; - - .WizardStep { - & > .step-content.scrollable { - max-height: none; - } - - & > :last-child { - padding: $padding * 2; - } - } - } - - .log-controls { - padding-bottom: $padding * 2; - - .time-range { - flex-grow: 2; - text-align: center; - } - - .controls { - width: 100%; - } - - .control-buttons { - margin-right: 0; - white-space: nowrap; - - .Icon { - border-radius: $radius; - padding: 3px; - - &:hover { - color: $textColorPrimary; - background: #f4f4f4; - } - - &.active { - color: $primary; - background: #f4f4f4; - } - } - } - - @include media("<=desktop") { - flex-direction: column; - align-items: start; - - .container { - width: 100%; - } - - .controls { - margin-top: $margin * 2; - - .time-range { - text-align: left; - } - } - } - } - - .logs-area { - position: relative; - @include custom-scrollbar; - - // fix for `this.logsArea.scrollTop = this.logsArea.scrollHeight` - // `overflow: overlay` don't allow scroll to the last line - overflow: auto; - - color: #C5C8C6; - background: #1D1F21; - line-height: var(--log-line-height); - border-radius: 2px; - height: 45vh; - padding: $padding / 4 $padding; - font-family: $font-monospace; - font-size: smaller; - white-space: pre; - - .no-logs { - text-align: center; - } - } - - .new-logs-sep { - position: relative; - display: block; - height: 0; - border-top: 1px solid $primary; - margin: $margin * 2; - - &:after { - position: absolute; - left: 50%; - transform: translate(-50%, -50%); - content: 'new'; - background: $primary; - color: white; - padding: $padding / 3 $padding /2; - border-radius: $radius; - } - } -} \ No newline at end of file diff --git a/src/renderer/components/+workloads-pods/pod-logs-dialog.tsx b/src/renderer/components/+workloads-pods/pod-logs-dialog.tsx deleted file mode 100644 index d7ff3863cc..0000000000 --- a/src/renderer/components/+workloads-pods/pod-logs-dialog.tsx +++ /dev/null @@ -1,307 +0,0 @@ -import "./pod-logs-dialog.scss"; - -import React from "react"; -import { observable } from "mobx"; -import { observer } from "mobx-react"; -import { t, Trans } from "@lingui/macro"; -import { _i18n } from "../../i18n"; -import { Dialog, DialogProps } from "../dialog"; -import { Wizard, WizardStep } from "../wizard"; -import { IPodContainer, Pod, podsApi } from "../../api/endpoints"; -import { Icon } from "../icon"; -import { Select, SelectOption } from "../select"; -import { Spinner } from "../spinner"; -import { cssNames, downloadFile, interval } from "../../utils"; -import AnsiUp from "ansi_up"; -import DOMPurify from "dompurify" - -interface IPodLogsDialogData { - pod: Pod; - container?: IPodContainer; -} - -interface Props extends Partial { -} - -@observer -export class PodLogsDialog extends React.Component { - @observable static isOpen = false; - @observable static data: IPodLogsDialogData = null; - - static open(pod: Pod, container?: IPodContainer) { - PodLogsDialog.isOpen = true; - PodLogsDialog.data = { pod, container }; - } - - static close() { - PodLogsDialog.isOpen = false; - } - - get data() { - return PodLogsDialog.data; - } - - private logsArea: HTMLDivElement; - private refresher = interval(5, () => this.load()); - private containers: IPodContainer[] = [] - private initContainers: IPodContainer[] = [] - private lastLineIsShown = true; // used for proper auto-scroll content after refresh - private colorConverter = new AnsiUp(); - - @observable logs = ""; // latest downloaded logs for pod - @observable newLogs = ""; // new logs since dialog is open - @observable logsReady = false; - @observable selectedContainer: IPodContainer; - @observable showTimestamps = true; - @observable tailLines = 1000; - - lineOptions = [ - { label: _i18n._(t`All logs`), value: Number.MAX_SAFE_INTEGER }, - { label: 1000, value: 1000 }, - { label: 10000, value: 10000 }, - { label: 100000, value: 100000 }, - ] - - onOpen = async () => { - const { pod, container } = this.data; - this.containers = pod.getContainers(); - this.initContainers = pod.getInitContainers(); - this.selectedContainer = container || this.containers[0]; - await this.load(); - this.refresher.start(); - } - - onClose = () => { - this.resetLogs(); - this.refresher.stop(); - } - - close = () => { - PodLogsDialog.close(); - } - - load = async () => { - if (!this.data) return; - const { pod } = this.data; - try { - // if logs already loaded, check the latest timestamp for getting updates only from this point - const logsTimestamps = this.getTimestamps(this.newLogs || this.logs); - let lastLogDate = new Date(0) - if (logsTimestamps) { - lastLogDate = new Date(logsTimestamps.slice(-1)[0]); - lastLogDate.setSeconds(lastLogDate.getSeconds() + 1); // avoid duplicates from last second - } - const namespace = pod.getNs(); - const name = pod.getName(); - const logs = await podsApi.getLogs({ namespace, name }, { - container: this.selectedContainer.name, - timestamps: true, - tailLines: this.tailLines ? this.tailLines : undefined, - sinceTime: lastLogDate.toISOString(), - }); - if (!this.logs) { - this.logs = logs; - } - else if (logs) { - this.newLogs = `${this.newLogs}\n${logs}`.trim(); - } - } catch (error) { - this.logs = [ - _i18n._(t`Failed to load logs: ${error.message}`), - _i18n._(t`Reason: ${error.reason} (${error.code})`), - ].join("\n") - } - this.logsReady = true; - } - - reload = async () => { - this.resetLogs(); - this.refresher.stop(); - await this.load(); - this.refresher.start(); - } - - componentDidUpdate() { - // scroll logs only when it's already in the end, - // otherwise it can interrupt reading by jumping after loading new logs update - if (this.logsArea && this.lastLineIsShown) { - this.logsArea.scrollTop = this.logsArea.scrollHeight; - } - } - - onScroll = (evt: React.UIEvent) => { - const logsArea = evt.currentTarget; - const { scrollHeight, clientHeight, scrollTop } = logsArea; - this.lastLineIsShown = clientHeight + scrollTop === scrollHeight; - }; - - getLogs() { - const { logs, newLogs, showTimestamps } = this; - return { - logs: showTimestamps ? logs : this.removeTimestamps(logs), - newLogs: showTimestamps ? newLogs : this.removeTimestamps(newLogs), - } - } - - getTimestamps(logs: string) { - return logs.match(/^\d+\S+/gm); - } - - removeTimestamps(logs: string) { - return logs.replace(/^\d+.*?\s/gm, ""); - } - - resetLogs() { - this.logs = ""; - this.newLogs = ""; - this.lastLineIsShown = true; - this.logsReady = false; - } - - onContainerChange = (option: SelectOption) => { - this.selectedContainer = this.containers - .concat(this.initContainers) - .find(container => container.name === option.value); - this.reload(); - } - - onTailLineChange = (option: SelectOption) => { - this.tailLines = option.value; - this.reload(); - } - - formatOptionLabel = (option: SelectOption) => { - const { value, label } = option; - return label || <> {value}; - } - - toggleTimestamps = () => { - this.showTimestamps = !this.showTimestamps; - } - - downloadLogs = () => { - const { logs, newLogs } = this.getLogs(); - const fileName = this.selectedContainer.name + ".log"; - const fileContents = logs + newLogs; - downloadFile(fileName, fileContents, "text/plain"); - } - - get containerSelectOptions() { - return [ - { - label: _i18n._(t`Containers`), - options: this.containers.map(container => { - return { value: container.name } - }), - }, - { - label: _i18n._(t`Init Containers`), - options: this.initContainers.map(container => { - return { value: container.name } - }), - } - ]; - } - - renderControlsPanel() { - const { logsReady, showTimestamps } = this; - if (!logsReady) return; - const timestamps = this.getTimestamps(this.logs + this.newLogs); - let from = ""; - let to = ""; - if (timestamps) { - from = new Date(timestamps[0]).toLocaleString(); - to = new Date(timestamps[timestamps.length - 1]).toLocaleString(); - } - return ( -
-
- {timestamps && From {from} to {to}} -
-
- - -
-
- ) - } - - renderLogs() { - if (!this.logsReady) { - return - } - const { logs, newLogs } = this.getLogs(); - if (!logs && !newLogs) { - return

There are no logs available for container.

- } - return ( - <> -
- {newLogs && ( - <> -

-

- - )} - - ); - } - - render() { - const { ...dialogProps } = this.props; - const { selectedContainer, tailLines } = this; - const podName = this.data ? this.data.pod.getName() : ""; - const header =
{podName} Logs
; - return ( - - - Close}> -
-
- Container - {selectedContainer && ( - -
- {this.renderControlsPanel()} -
-
this.logsArea = e}> - {this.renderLogs()} -
-
-
-
- ) - } -} diff --git a/src/renderer/components/+workloads-pods/pod-menu.tsx b/src/renderer/components/+workloads-pods/pod-menu.tsx index cf6fcd360f..64b5d1f5eb 100644 --- a/src/renderer/components/+workloads-pods/pod-menu.tsx +++ b/src/renderer/components/+workloads-pods/pod-menu.tsx @@ -3,15 +3,15 @@ import "./pod-menu.scss"; import React from "react"; import { t, Trans } from "@lingui/macro"; import { MenuItem, SubMenu } from "../menu"; -import { IPodContainer, Pod, nodesApi } from "../../api/endpoints"; +import { IPodContainer, Pod } from "../../api/endpoints"; import { Icon } from "../icon"; import { StatusBrick } from "../status-brick"; -import { PodLogsDialog } from "./pod-logs-dialog"; import { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object/kube-object-menu"; import { cssNames, prevDefault } from "../../utils"; import { terminalStore, createTerminalTab } from "../dock/terminal.store"; import { _i18n } from "../../i18n"; import { hideDetails } from "../../navigation"; +import { createPodLogsTab } from "../dock/pod-logs.store"; interface Props extends KubeObjectMenuProps { } @@ -42,7 +42,16 @@ export class PodMenu extends React.Component { } showLogs(container: IPodContainer) { - PodLogsDialog.open(this.props.object, container); + hideDetails(); + const pod = this.props.object; + createPodLogsTab({ + pod, + containers: pod.getContainers(), + initContainers: pod.getInitContainers(), + selectedContainer: container, + showTimestamps: false, + tailLines: 1000 + }); } renderShellMenu() { diff --git a/src/renderer/components/app.tsx b/src/renderer/components/app.tsx index aee156ff31..cf8ba01003 100755 --- a/src/renderer/components/app.tsx +++ b/src/renderer/components/app.tsx @@ -23,7 +23,6 @@ import { eventRoute } from "./+events"; import { Apps, appsRoute } from "./+apps"; import { KubeObjectDetails } from "./kube-object/kube-object-details"; import { AddRoleBindingDialog } from "./+user-management-roles-bindings"; -import { PodLogsDialog } from "./+workloads-pods/pod-logs-dialog"; import { DeploymentScaleDialog } from "./+workloads-deployments/deployment-scale-dialog"; import { CronJobTriggerDialog } from "./+workloads-cronjobs/cronjob-trigger-dialog"; import { CustomResources } from "./+custom-resources/custom-resources"; @@ -82,7 +81,6 @@ export class App extends React.Component { - diff --git a/src/renderer/components/cluster-manager/cluster-status.tsx b/src/renderer/components/cluster-manager/cluster-status.tsx index 846b26a504..5a488c4405 100644 --- a/src/renderer/components/cluster-manager/cluster-status.tsx +++ b/src/renderer/components/cluster-manager/cluster-status.tsx @@ -47,13 +47,14 @@ export class ClusterStatus extends React.Component { ipcRenderer.removeAllListeners(`kube-auth:${this.props.clusterId}`); } - activateCluster = async () => { - await clusterIpc.activate.invokeFromRenderer(this.props.clusterId); + activateCluster = async (force = false) => { + await clusterIpc.activate.invokeFromRenderer(this.props.clusterId, force); } reconnect = async () => { + this.authOutput = [] this.isReconnecting = true; - await this.activateCluster(); + await this.activateCluster(true); this.isReconnecting = false; } diff --git a/src/renderer/components/dock/dock.store.ts b/src/renderer/components/dock/dock.store.ts index 2fed511e75..2ec01df8de 100644 --- a/src/renderer/components/dock/dock.store.ts +++ b/src/renderer/components/dock/dock.store.ts @@ -11,6 +11,7 @@ export enum TabKind { EDIT_RESOURCE = "edit-resource", INSTALL_CHART = "install-chart", UPGRADE_CHART = "upgrade-chart", + POD_LOGS = "pod-logs", } export interface IDockTab { diff --git a/src/renderer/components/dock/dock.tsx b/src/renderer/components/dock/dock.tsx index c020d20672..f4936417e6 100644 --- a/src/renderer/components/dock/dock.tsx +++ b/src/renderer/components/dock/dock.tsx @@ -22,6 +22,8 @@ import { createResourceTab, isCreateResourceTab } from "./create-resource.store" import { isEditResourceTab } from "./edit-resource.store"; import { isInstallChartTab } from "./install-chart.store"; import { isUpgradeChartTab } from "./upgrade-chart.store"; +import { PodLogs } from "./pod-logs"; +import { isPodLogsTab } from "./pod-logs.store"; interface Props { className?: string; @@ -59,6 +61,9 @@ export class Dock extends React.Component { if (isInstallChartTab(tab) || isUpgradeChartTab(tab)) { return } /> } + if (isPodLogsTab(tab)) { + return + } } renderTabContent() { @@ -71,6 +76,7 @@ export class Dock extends React.Component { {isInstallChartTab(tab) && } {isUpgradeChartTab(tab) && } {isTerminalTab(tab) && } + {isPodLogsTab(tab) && }
) } diff --git a/src/renderer/components/dock/info-panel.tsx b/src/renderer/components/dock/info-panel.tsx index 122fea6d9f..777aa01027 100644 --- a/src/renderer/components/dock/info-panel.tsx +++ b/src/renderer/components/dock/info-panel.tsx @@ -13,7 +13,7 @@ import { Notifications } from "../notifications"; interface Props extends OptionalProps { tabId: TabId; - submit: () => Promise; + submit?: () => Promise; } interface OptionalProps { @@ -23,6 +23,7 @@ interface OptionalProps { submitLabel?: ReactNode; submittingMessage?: ReactNode; disableSubmit?: boolean; + showButtons?: boolean showSubmitClose?: boolean; showInlineInfo?: boolean; showNotifications?: boolean; @@ -33,6 +34,7 @@ export class InfoPanel extends Component { static defaultProps: OptionalProps = { submitLabel: Submit, submittingMessage: Submitting.., + showButtons: true, showSubmitClose: true, showInlineInfo: true, showNotifications: true, @@ -87,7 +89,7 @@ export class InfoPanel extends Component { } render() { - const { className, controls, submitLabel, disableSubmit, error, submittingMessage, showSubmitClose } = this.props; + const { className, controls, submitLabel, disableSubmit, error, submittingMessage, showButtons, showSubmitClose } = this.props; const { submit, close, submitAndClose, waiting } = this; const isDisabled = !!(disableSubmit || waiting || error); return ( @@ -98,22 +100,26 @@ export class InfoPanel extends Component {
{waiting ? <> {submittingMessage} : this.renderErrorIcon()}
-
); diff --git a/src/renderer/components/dock/pod-logs.scss b/src/renderer/components/dock/pod-logs.scss new file mode 100644 index 0000000000..73c6f523fb --- /dev/null +++ b/src/renderer/components/dock/pod-logs.scss @@ -0,0 +1,43 @@ +.PodLogs { + .logs { + @include custom-scrollbar; + + // fix for `this.logsElement.scrollTop = this.logsElement.scrollHeight` + // `overflow: overlay` don't allow scroll to the last line + overflow: auto; + + color: $textColorAccent; + background: $logsBackground; + line-height: var(--log-line-height); + border-radius: 2px; + padding: $padding * 2; + font-family: $font-monospace; + font-size: smaller; + white-space: pre; + flex-grow: 1; + + > div { + // Provides font better readability on large screens + -webkit-font-smoothing: subpixel-antialiased; + } + } + + .new-logs-sep { + position: relative; + display: block; + height: 0; + border-top: 1px solid $primary; + margin: $margin * 2; + + &:after { + position: absolute; + left: 50%; + transform: translate(-50%, -50%); + content: 'new'; + background: $primary; + color: white; + padding: $padding / 3; + border-radius: $radius; + } + } +} \ No newline at end of file diff --git a/src/renderer/components/dock/pod-logs.store.ts b/src/renderer/components/dock/pod-logs.store.ts new file mode 100644 index 0000000000..bf2bf9eb5c --- /dev/null +++ b/src/renderer/components/dock/pod-logs.store.ts @@ -0,0 +1,126 @@ +import { autorun, observable } from "mobx"; +import { Pod, IPodContainer, podsApi } from "../../api/endpoints"; +import { autobind, interval } from "../../utils"; +import { DockTabStore } from "./dock-tab.store"; +import { dockStore, IDockTab, TabKind } from "./dock.store"; +import { t } from "@lingui/macro"; +import { _i18n } from "../../i18n"; + +export interface IPodLogsData { + pod: Pod; + selectedContainer: IPodContainer + containers: IPodContainer[] + initContainers: IPodContainer[] + showTimestamps: boolean + tailLines: number +} + +type TabId = string; + +interface PodLogs { + oldLogs?: string + newLogs?: string +} + +@autobind() +export class PodLogsStore extends DockTabStore { + private refresher = interval(10, () => this.load(dockStore.selectedTabId)); + + @observable logs = observable.map(); + + constructor() { + super({ + storageName: "pod_logs" + }); + autorun(() => { + const { selectedTab, isOpen } = dockStore; + if (isPodLogsTab(selectedTab) && isOpen) { + this.refresher.start(); + } else { + this.refresher.stop(); + } + }, { delay: 500 }); + } + + load = async (tabId: TabId) => { + if (!this.logs.has(tabId)) { + this.logs.set(tabId, { oldLogs: "", newLogs: "" }) + } + const data = this.getData(tabId); + const { oldLogs, newLogs } = this.logs.get(tabId); + const { selectedContainer, tailLines } = data; + const pod = new Pod(data.pod); + try { + // if logs already loaded, check the latest timestamp for getting updates only from this point + const logsTimestamps = this.getTimestamps(newLogs || oldLogs); + let lastLogDate = new Date(0); + if (logsTimestamps) { + lastLogDate = new Date(logsTimestamps.slice(-1)[0]); + lastLogDate.setSeconds(lastLogDate.getSeconds() + 1); // avoid duplicates from last second + } + const namespace = pod.getNs(); + const name = pod.getName(); + const loadedLogs = await podsApi.getLogs({ namespace, name }, { + sinceTime: lastLogDate.toISOString(), + timestamps: true, // Always setting timestampt to separate old logs from new ones + container: selectedContainer.name, + tailLines: tailLines, + }); + if (!oldLogs) { + this.logs.set(tabId, { oldLogs: loadedLogs, newLogs }); + } else { + this.logs.set(tabId, { oldLogs, newLogs: loadedLogs }); + } + } catch (error) { + this.logs.set(tabId, { + oldLogs: [ + _i18n._(t`Failed to load logs: ${error.message}`), + _i18n._(t`Reason: ${error.reason} (${error.code})`) + ].join("\n"), + newLogs + }); + } + } + + getTimestamps(logs: string) { + return logs.match(/^\d+\S+/gm); + } + + removeTimestamps(logs: string) { + return logs.replace(/^\d+.*?\s/gm, ""); + } + + clearLogs(tabId: TabId) { + this.logs.delete(tabId); + } + + clearData(tabId: TabId) { + this.data.delete(tabId); + this.clearLogs(tabId); + } +} + +export const podLogsStore = new PodLogsStore(); + +export function createPodLogsTab(data: IPodLogsData, tabParams: Partial = {}) { + const podId = data.pod.getId(); + let tab = dockStore.getTabById(podId); + if (tab) { + dockStore.open(); + dockStore.selectTab(tab.id); + return; + } + // If no existent tab found + tab = dockStore.createTab({ + id: podId, + kind: TabKind.POD_LOGS, + title: `Logs: ${data.pod.getName()}`, + ...tabParams + }, false); + podLogsStore.setData(tab.id, data); + return tab; +} + +export function isPodLogsTab(tab: IDockTab) { + return tab && tab.kind === TabKind.POD_LOGS; +} diff --git a/src/renderer/components/dock/pod-logs.tsx b/src/renderer/components/dock/pod-logs.tsx new file mode 100644 index 0000000000..6f97163bde --- /dev/null +++ b/src/renderer/components/dock/pod-logs.tsx @@ -0,0 +1,235 @@ +import "./pod-logs.scss"; +import React from "react"; +import AnsiUp from "ansi_up"; +import DOMPurify from "dompurify"; +import { t, Trans } from "@lingui/macro"; +import { computed, observable, reaction } from "mobx"; +import { disposeOnUnmount, observer } from "mobx-react"; +import { _i18n } from "../../i18n"; +import { autobind, cssNames, downloadFile } from "../../utils"; +import { Icon } from "../icon"; +import { Select, SelectOption } from "../select"; +import { Spinner } from "../spinner"; +import { IDockTab } from "./dock.store"; +import { InfoPanel } from "./info-panel"; +import { IPodLogsData, podLogsStore } from "./pod-logs.store"; + +interface Props { + className?: string + tab: IDockTab +} + +@observer +export class PodLogs extends React.Component { + @observable ready = false; + + private logsElement: HTMLDivElement; + private lastLineIsShown = true; // used for proper auto-scroll content after refresh + private colorConverter = new AnsiUp(); + private lineOptions = [ + { label: _i18n._(t`All logs`), value: Number.MAX_SAFE_INTEGER }, + { label: 1000, value: 1000 }, + { label: 10000, value: 10000 }, + { label: 100000, value: 100000 }, + ]; + + componentDidMount() { + disposeOnUnmount(this, + reaction(() => this.props.tab.id, async () => { + if (podLogsStore.logs.has(this.tabId)) { + this.ready = true; + return; + } + await this.load(); + }, { fireImmediately: true }) + ); + } + + componentDidUpdate() { + // scroll logs only when it's already in the end, + // otherwise it can interrupt reading by jumping after loading new logs update + if (this.logsElement && this.lastLineIsShown) { + this.logsElement.scrollTop = this.logsElement.scrollHeight; + } + } + + get tabData() { + return podLogsStore.getData(this.tabId); + } + + get tabId() { + return this.props.tab.id; + } + + @autobind() + save(data: Partial) { + podLogsStore.setData(this.tabId, { ...this.tabData, ...data }); + } + + load = async () => { + this.ready = false; + await podLogsStore.load(this.tabId); + this.ready = true; + } + + reload = async () => { + podLogsStore.clearLogs(this.tabId); + this.lastLineIsShown = true; + await this.load(); + } + + @computed + get logs() { + if (!podLogsStore.logs.has(this.tabId)) return; + const { oldLogs, newLogs } = podLogsStore.logs.get(this.tabId); + const { getData, removeTimestamps } = podLogsStore; + const { showTimestamps } = getData(this.tabId); + return { + oldLogs: showTimestamps ? oldLogs : removeTimestamps(oldLogs), + newLogs: showTimestamps ? newLogs : removeTimestamps(newLogs) + } + } + + toggleTimestamps = () => { + this.save({ showTimestamps: !this.tabData.showTimestamps }); + } + + onScroll = (evt: React.UIEvent) => { + const logsArea = evt.currentTarget; + const { scrollHeight, clientHeight, scrollTop } = logsArea; + this.lastLineIsShown = clientHeight + scrollTop === scrollHeight; + }; + + downloadLogs = () => { + const { oldLogs, newLogs } = this.logs; + const { pod, selectedContainer } = this.tabData; + const fileName = selectedContainer ? selectedContainer.name : pod.getName(); + const fileContents = oldLogs + newLogs; + downloadFile(fileName + ".log", fileContents, "text/plain"); + } + + onContainerChange = (option: SelectOption) => { + const { containers, initContainers } = this.tabData; + this.save({ + selectedContainer: containers + .concat(initContainers) + .find(container => container.name === option.value) + }) + this.reload(); + } + + onTailLineChange = (option: SelectOption) => { + this.save({ tailLines: option.value }) + this.reload(); + } + + get containerSelectOptions() { + const { containers, initContainers } = this.tabData; + return [ + { + label: _i18n._(t`Containers`), + options: containers.map(container => { + return { value: container.name } + }), + }, + { + label: _i18n._(t`Init Containers`), + options: initContainers.map(container => { + return { value: container.name } + }), + } + ]; + } + + formatOptionLabel = (option: SelectOption) => { + const { value, label } = option; + return label || <> {value}; + } + + renderControls() { + if (!this.ready) return null; + const { selectedContainer, showTimestamps, tailLines } = this.tabData; + const timestamps = podLogsStore.getTimestamps(podLogsStore.logs.get(this.tabId).oldLogs); + return ( +
+ Container + +
+ {timestamps && ( + <> + Since{" "} + {new Date(timestamps[0]).toLocaleString()} + + )} +
+
+ + +
+
+ ); + } + + renderLogs() { + if (!this.ready) { + return ; + } + const { oldLogs, newLogs } = this.logs; + if (!oldLogs && !newLogs) { + return ( +
+ There are no logs available for container. +
+ ); + } + return ( + <> +
+ {newLogs && ( + <> +

+

+ + )} + + ); + } + + render() { + const { className } = this.props; + return ( +
+ +
this.logsElement = e}> + {this.renderLogs()} +
+
+ ); + } +} diff --git a/src/renderer/components/input/search-input.scss b/src/renderer/components/input/search-input.scss index 2f0570da86..a0c706bf3f 100644 --- a/src/renderer/components/input/search-input.scss +++ b/src/renderer/components/input/search-input.scss @@ -7,7 +7,11 @@ > label { color: inherit; border-radius: $radius; - padding: $padding / 1.33 $padding * 1.25; + padding: 6px 6px 6px 10px; + + .Icon { + height: $margin * 2; + } } &.compact { diff --git a/src/renderer/components/item-object-list/item-list-layout.scss b/src/renderer/components/item-object-list/item-list-layout.scss index 6b1623c395..f52afa56e0 100644 --- a/src/renderer/components/item-object-list/item-list-layout.scss +++ b/src/renderer/components/item-object-list/item-list-layout.scss @@ -15,6 +15,14 @@ white-space: nowrap; } + .NamespaceSelect { + .Select { + &__value-container { + margin-bottom: 0; + } + } + } + .SearchInput { label { background: none; diff --git a/src/renderer/themes/kontena-dark.json b/src/renderer/themes/kontena-dark.json index 1071024841..25defd41bf 100644 --- a/src/renderer/themes/kontena-dark.json +++ b/src/renderer/themes/kontena-dark.json @@ -60,6 +60,7 @@ "dockHeadBackground": "#2e3136", "dockInfoBackground": "#1e2125", "dockInfoBorderColor": "#303136", + "logsBackground": "#000000", "terminalBackground": "#000000", "terminalForeground": "#ffffff", "terminalCursor": "#ffffff", diff --git a/src/renderer/themes/kontena-light.json b/src/renderer/themes/kontena-light.json index f39fa53d00..a1eca8d51c 100644 --- a/src/renderer/themes/kontena-light.json +++ b/src/renderer/themes/kontena-light.json @@ -61,6 +61,7 @@ "dockHeadBackground": "#e8e8e8", "dockInfoBackground": "#e8e8e8", "dockInfoBorderColor": "#c9cfd3", + "logsBackground": "#ffffff", "terminalBackground": "#ffffff", "terminalForeground": "#2d2d2d", "terminalCursor": "#2d2d2d", diff --git a/src/renderer/themes/theme-vars.scss b/src/renderer/themes/theme-vars.scss index c6c3072b8f..6a48721fcc 100644 --- a/src/renderer/themes/theme-vars.scss +++ b/src/renderer/themes/theme-vars.scss @@ -91,6 +91,9 @@ $terminalBrightMagenta: var(--terminalBrightMagenta); $terminalBrightCyan: var(--terminalBrightCyan); $terminalBrightWhite: var(--terminalBrightWhite); +// Logs +$logsBackground: var(--logsBackground); + // Dialogs $dialogTextColor: var(--dialogTextColor); $dialogBackground: var(--dialogBackground); diff --git a/types/command-exists.d.ts b/types/command-exists.d.ts new file mode 100644 index 0000000000..b5375ae390 --- /dev/null +++ b/types/command-exists.d.ts @@ -0,0 +1,16 @@ +// Type definitions for command-exists 1.2 +// Project: https://github.com/mathisonian/command-exists +// Definitions by: BendingBender +// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped + +export = commandExists; + +declare function commandExists(commandName: string): Promise; +declare function commandExists( + commandName: string, + cb: (error: null, exists: boolean) => void +): void; + +declare namespace commandExists { + function sync(commandName: string): boolean; +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 39f1e91dac..8a9976ebc6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3918,6 +3918,11 @@ combined-stream@^1.0.6, combined-stream@~1.0.6: dependencies: delayed-stream "~1.0.0" +command-exists@1.2.9: + version "1.2.9" + resolved "https://registry.yarnpkg.com/command-exists/-/command-exists-1.2.9.tgz#c50725af3808c8ab0260fd60b01fbfa25b954f69" + integrity sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w== + commander@*, commander@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae" @@ -7134,6 +7139,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" @@ -11585,6 +11597,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"