From e8d8f8f610216fb1ec45fe4b02911f92fbe584f2 Mon Sep 17 00:00:00 2001 From: Jim Ehrismann <40840436+jim-docker@users.noreply.github.com> Date: Thu, 20 Jan 2022 11:17:11 -0500 Subject: [PATCH] Release/v5.3.4 (#4721) * Use electron.clipboard for all clipboard uses (#4535) Signed-off-by: Jim Ehrismann * Fix ERR_UNSAFE_PORT from LensProxy (#4558) * Fix ERR_UNSAFE_PORT from LensProxy - Use the current list of ports from chromium as it is much easier to just reject using one of those instead of trying to handle the ERR_UNSAFE_PORT laod error from a BrowserWindow.on("did-fail-load") Signed-off-by: Sebastian Malton * Move all port handling into LensProxy Signed-off-by: Sebastian Malton * don't use so many exceptions Signed-off-by: Sebastian Malton Signed-off-by: Jim Ehrismann * Fix not being able to clear set cluster icon (#4555) Signed-off-by: Jim Ehrismann * Fix extension engine range not working for some ^ ranges (#4554) Signed-off-by: Jim Ehrismann * Fix crash on NetworkPolicy when matchLabels is missing (#4500) Signed-off-by: Jim Ehrismann * Replace all uses of promiseExec with promiseExecFile (#4514) Signed-off-by: Jim Ehrismann * Less noisy metrics-not-available error logging (#4602) Signed-off-by: Jari Kolehmainen Signed-off-by: Jim Ehrismann * Fix close button overflow in Preferences (#4611) * Adding basic colors to tailwind theme Signed-off-by: Alex Andreev * Using tailwind inline to style close button Signed-off-by: Alex Andreev * Make Select look similar to inputs Signed-off-by: Alex Andreev * Moving styles into separate module Signed-off-by: Alex Andreev * Convert tailwind commands to css Signed-off-by: Alex Andreev Signed-off-by: Jim Ehrismann * Fix prometheus operator metrics work out of the box (#4617) Signed-off-by: Lauri Nevala Signed-off-by: Jim Ehrismann * Fix CRD.getPreferedVersion() to work based on apiVersion (#4553) * Fix CRD.getPreferedVersion() to work based on apiVersion Signed-off-by: Sebastian Malton * Add tests Signed-off-by: Sebastian Malton Signed-off-by: Jim Ehrismann * Fix crash for KubeObjectStore.loadAll() (#4675) Signed-off-by: Sebastian Malton Signed-off-by: Jim Ehrismann * Convert CloseButton styles out from css modules (#4723) * Convert CloseButton styles out from css modules Signed-off-by: Alex Andreev * Fix close button styling Signed-off-by: Alex Andreev * release v5.3.4 Signed-off-by: Jim Ehrismann Co-authored-by: Sebastian Malton Co-authored-by: Jari Kolehmainen Co-authored-by: Alex Andreev Co-authored-by: Lauri Nevala --- .../__tests__/app-preferences.tests.ts | 3 +- package.json | 2 +- src/common/cluster-types.ts | 6 +- src/common/k8s-api/__tests__/crd.test.ts | 15 +- src/common/k8s-api/endpoints/crd.api.ts | 97 ++++--- src/common/k8s-api/kube-object.store.ts | 3 +- src/common/system-ca.ts | 22 +- src/common/utils/promise-exec.ts | 3 +- .../__tests__/extension-compatibility.test.ts | 44 +-- src/extensions/extension-compatibility.ts | 40 ++- src/main/cluster-manager.ts | 12 +- src/main/helm/helm-release-manager.ts | 262 +++++++++++------- src/main/helm/helm-repo-manager.ts | 104 ++++--- src/main/helm/helm-service.ts | 16 +- src/main/kubectl.ts | 9 +- src/main/lens-proxy.ts | 65 ++++- src/main/prometheus/operator.ts | 18 +- src/main/routes/metrics-route.ts | 2 +- .../__tests__/network-policy-details.test.tsx | 15 + .../network-policy-details.module.css | 9 + .../network-policy-details.tsx | 57 +++- .../+preferences/add-helm-repo-dialog.tsx | 4 +- .../components/clipboard/clipboard.tsx | 88 ------ .../components/cluster-icon-settings.tsx | 6 +- .../components/dialog/logs-dialog.tsx | 7 +- .../kubeconfig-dialog/kubeconfig-dialog.tsx | 14 +- .../index.ts => layout/close-button.scss} | 34 ++- .../close-button.tsx} | 23 +- .../components/layout/setting-layout.scss | 36 +-- .../components/layout/setting-layout.tsx | 11 +- src/renderer/components/select/select.scss | 7 +- src/renderer/utils/copyToClipboard.ts | 52 ---- src/renderer/utils/index.ts | 1 - tailwind.config.js | 9 +- 34 files changed, 630 insertions(+), 466 deletions(-) delete mode 100644 src/renderer/components/clipboard/clipboard.tsx rename src/renderer/components/{clipboard/index.ts => layout/close-button.scss} (65%) rename src/renderer/components/{clipboard/clipboard.scss => layout/close-button.tsx} (69%) delete mode 100644 src/renderer/utils/copyToClipboard.ts diff --git a/integration/__tests__/app-preferences.tests.ts b/integration/__tests__/app-preferences.tests.ts index 47dffd6a9066..8cdf19a5488a 100644 --- a/integration/__tests__/app-preferences.tests.ts +++ b/integration/__tests__/app-preferences.tests.ts @@ -71,7 +71,8 @@ describe("preferences page tests", () => { } }, 10*60*1000); - utils.itIf(process.platform !== "win32")("ensures helm repos", async () => { + // Skipping, but will turn it on again in the follow up PR + it.skip("ensures helm repos", async () => { await window.click("[data-testid=kubernetes-tab]"); await window.waitForSelector("[data-testid=repository-name]", { timeout: 140_000, diff --git a/package.json b/package.json index 4dcf5a5c16ed..f3430c1c88ea 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "productName": "OpenLens", "description": "OpenLens - Open Source IDE for Kubernetes", "homepage": "https://github.com/lensapp/lens", - "version": "5.3.3", + "version": "5.3.4", "main": "static/build/main.js", "copyright": "© 2021 OpenLens Authors", "license": "MIT", diff --git a/src/common/cluster-types.ts b/src/common/cluster-types.ts index cb958953f6d4..09e4435530f6 100644 --- a/src/common/cluster-types.ts +++ b/src/common/cluster-types.ts @@ -90,7 +90,11 @@ export interface ClusterPreferences extends ClusterPrometheusPreferences { terminalCWD?: string; clusterName?: string; iconOrder?: number; - icon?: string; + /** + * The src for the cluster. If set to `null` that means that it was + * cleared by preferences. + */ + icon?: string | null; httpsProxy?: string; hiddenMetrics?: string[]; nodeShellImage?: string; diff --git a/src/common/k8s-api/__tests__/crd.test.ts b/src/common/k8s-api/__tests__/crd.test.ts index 0eba45e2cd21..4403a9c7a4e2 100644 --- a/src/common/k8s-api/__tests__/crd.test.ts +++ b/src/common/k8s-api/__tests__/crd.test.ts @@ -19,10 +19,10 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { CustomResourceDefinition } from "../endpoints"; +import { CustomResourceDefinition, CustomResourceDefinitionSpec } from "../endpoints"; describe("Crds", () => { - describe("getVersion", () => { + describe("getVersion()", () => { it("should throw if none of the versions are served", () => { const crd = new CustomResourceDefinition({ apiVersion: "apiextensions.k8s.io/v1", @@ -136,7 +136,7 @@ describe("Crds", () => { expect(crd.getVersion()).toBe("123"); }); - it("should get the version name from the version field", () => { + it("should get the version name from the version field, ignoring versions on v1beta", () => { const crd = new CustomResourceDefinition({ apiVersion: "apiextensions.k8s.io/v1beta1", kind: "CustomResourceDefinition", @@ -147,7 +147,14 @@ describe("Crds", () => { }, spec: { version: "abc", - }, + versions: [ + { + name: "foobar", + served: true, + storage: true, + }, + ], + } as CustomResourceDefinitionSpec, }); expect(crd.getVersion()).toBe("abc"); diff --git a/src/common/k8s-api/endpoints/crd.api.ts b/src/common/k8s-api/endpoints/crd.api.ts index 4e2244d8a898..6b3f198f9de5 100644 --- a/src/common/k8s-api/endpoints/crd.api.ts +++ b/src/common/k8s-api/endpoints/crd.api.ts @@ -48,34 +48,36 @@ export interface CRDVersion { additionalPrinterColumns?: AdditionalPrinterColumnsV1[]; } -export interface CustomResourceDefinition { - spec: { - group: string; - /** - * @deprecated for apiextensions.k8s.io/v1 but used previously - */ - version?: string; - names: { - plural: string; - singular: string; - kind: string; - listKind: string; - }; - scope: "Namespaced" | "Cluster" | string; - /** - * @deprecated for apiextensions.k8s.io/v1 but used previously - */ - validation?: object; - versions?: CRDVersion[]; - conversion: { - strategy?: string; - webhook?: any; - }; - /** - * @deprecated for apiextensions.k8s.io/v1 but used previously - */ - additionalPrinterColumns?: AdditionalPrinterColumnsV1Beta[]; +export interface CustomResourceDefinitionSpec { + group: string; + /** + * @deprecated for apiextensions.k8s.io/v1 but used in v1beta1 + */ + version?: string; + names: { + plural: string; + singular: string; + kind: string; + listKind: string; }; + scope: "Namespaced" | "Cluster"; + /** + * @deprecated for apiextensions.k8s.io/v1 but used in v1beta1 + */ + validation?: object; + versions?: CRDVersion[]; + conversion: { + strategy?: string; + webhook?: any; + }; + /** + * @deprecated for apiextensions.k8s.io/v1 but used in v1beta1 + */ + additionalPrinterColumns?: AdditionalPrinterColumnsV1Beta[]; +} + +export interface CustomResourceDefinition { + spec: CustomResourceDefinitionSpec; status: { conditions: { lastTransitionTime: string; @@ -150,27 +152,30 @@ export class CustomResourceDefinition extends KubeObject { } getPreferedVersion(): CRDVersion { - // Prefer the modern `versions` over the legacy `version` - if (this.spec.versions) { - for (const version of this.spec.versions) { - if (version.storage) { - return version; + const { apiVersion } = this; + + switch (apiVersion) { + case "apiextensions.k8s.io/v1": + for (const version of this.spec.versions) { + if (version.storage) { + return version; + } } - } - } else if (this.spec.version) { - const { additionalPrinterColumns: apc } = this.spec; - const additionalPrinterColumns = apc?.map(({ JSONPath, ...apc }) => ({ ...apc, jsonPath: JSONPath })); - - return { - name: this.spec.version, - served: true, - storage: true, - schema: this.spec.validation, - additionalPrinterColumns, - }; + break; + case "apiextensions.k8s.io/v1beta1": + const { additionalPrinterColumns: apc } = this.spec; + const additionalPrinterColumns = apc?.map(({ JSONPath, ...apc }) => ({ ...apc, jsonPath: JSONPath })); + + return { + name: this.spec.version, + served: true, + storage: true, + schema: this.spec.validation, + additionalPrinterColumns, + }; } - throw new Error(`Failed to find a version for CustomResourceDefinition ${this.metadata.name}`); + throw new Error(`Unknown apiVersion=${apiVersion}: Failed to find a version for CustomResourceDefinition ${this.metadata.name}`); } getVersion() { @@ -197,7 +202,7 @@ export class CustomResourceDefinition extends KubeObject { const columns = this.getPreferedVersion().additionalPrinterColumns ?? []; return columns - .filter(column => column.name != "Age" && (ignorePriority || !column.priority)); + .filter(column => column.name.toLowerCase() != "age" && (ignorePriority || !column.priority)); } getValidation() { diff --git a/src/common/k8s-api/kube-object.store.ts b/src/common/k8s-api/kube-object.store.ts index 1832cdfa102c..f2e3c22980bf 100644 --- a/src/common/k8s-api/kube-object.store.ts +++ b/src/common/k8s-api/kube-object.store.ts @@ -235,8 +235,9 @@ export abstract class KubeObjectStore extends ItemStore } @action - async loadAll({ namespaces = this.context.contextNamespaces, merge = true, reqInit, onLoadFailure }: KubeObjectStoreLoadAllParams = {}): Promise { + async loadAll({ namespaces, merge = true, reqInit, onLoadFailure }: KubeObjectStoreLoadAllParams = {}): Promise { await this.contextReady; + namespaces ??= this.context.contextNamespaces; this.isLoading = true; try { diff --git a/src/common/system-ca.ts b/src/common/system-ca.ts index 22d85e3b4215..bc7231a5f60a 100644 --- a/src/common/system-ca.ts +++ b/src/common/system-ca.ts @@ -22,7 +22,7 @@ import { isMac, isWindows } from "./vars"; import wincaAPI from "win-ca/api"; import https from "https"; -import { promiseExec } from "./utils/promise-exec"; +import { promiseExecFile } from "./utils/promise-exec"; // DST Root CA X3, which was expired on 9.30.2021 export const DSTRootCAX3 = "-----BEGIN CERTIFICATE-----\nMIIDSjCCAjKgAwIBAgIQRK+wgNajJ7qJMDmGLvhAazANBgkqhkiG9w0BAQUFADA/\nMSQwIgYDVQQKExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMT\nDkRTVCBSb290IENBIFgzMB4XDTAwMDkzMDIxMTIxOVoXDTIxMDkzMDE0MDExNVow\nPzEkMCIGA1UEChMbRGlnaXRhbCBTaWduYXR1cmUgVHJ1c3QgQ28uMRcwFQYDVQQD\nEw5EU1QgUm9vdCBDQSBYMzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB\nAN+v6ZdQCINXtMxiZfaQguzH0yxrMMpb7NnDfcdAwRgUi+DoM3ZJKuM/IUmTrE4O\nrz5Iy2Xu/NMhD2XSKtkyj4zl93ewEnu1lcCJo6m67XMuegwGMoOifooUMM0RoOEq\nOLl5CjH9UL2AZd+3UWODyOKIYepLYYHsUmu5ouJLGiifSKOeDNoJjj4XLh7dIN9b\nxiqKqy69cK3FCxolkHRyxXtqqzTWMIn/5WgTe1QLyNau7Fqckh49ZLOMxt+/yUFw\n7BZy1SbsOFU5Q9D8/RhcQPGX69Wam40dutolucbY38EVAjqr2m7xPi71XAicPNaD\naeQQmxkqtilX4+U9m5/wAl0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNV\nHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFMSnsaR7LHH62+FLkHX/xBVghYkQMA0GCSqG\nSIb3DQEBBQUAA4IBAQCjGiybFwBcqR7uKGY3Or+Dxz9LwwmglSBd49lZRNI+DT69\nikugdB/OEIKcdBodfpga3csTS7MgROSR6cz8faXbauX+5v3gTt23ADq1cEmv8uXr\nAvHRAosZy5Q6XkjEGB5YGV8eAlrwDPGxrancWYaLbumR9YbK+rlmM6pZW87ipxZz\nR8srzJmwN0jP41ZL9c8PDHIyh8bwRLtTcm1D9SZImlJnt1ir/md2cXjbDaJWFBM5\nJDGFoqgCWjBH4d1QB7wCCZAA62RjYJsWvIjJEubSfZGL+T0yjWW06XyxV3bqxbYo\nOb8VZRzI9neWagqNdwvYkQsEjgfbKbYK7p2CNTUQ\n-----END CERTIFICATE-----\n"; @@ -33,19 +33,25 @@ export function isCertActive(cert: string) { return !isExpired; } +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions/Cheatsheet#other_assertions +const certSplitPattern = /(?=-----BEGIN\sCERTIFICATE-----)/g; + +async function execSecurity(...args: string[]): Promise { + const { stdout } = await promiseExecFile("/usr/bin/security", args); + + return stdout.split(certSplitPattern); +} + /** * Get root CA certificate from MacOSX system keychain * Only return non-expred certificates. */ export async function getMacRootCA() { // inspired mac-ca https://github.com/jfromaniello/mac-ca - const args = "find-certificate -a -p"; - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions/Cheatsheet#other_assertions - const splitPattern = /(?=-----BEGIN\sCERTIFICATE-----)/g; - const systemRootCertsPath = "/System/Library/Keychains/SystemRootCertificates.keychain"; - const bin = "/usr/bin/security"; - const trusted = (await promiseExec(`${bin} ${args}`)).stdout.toString().split(splitPattern); - const rootCA = (await promiseExec(`${bin} ${args} ${systemRootCertsPath}`)).stdout.toString().split(splitPattern); + const [trusted, rootCA] = await Promise.all([ + execSecurity("find-certificate", "-a", "-p"), + execSecurity("find-certificate", "-a", "-p", "/System/Library/Keychains/SystemRootCertificates.keychain"), + ]); return [...new Set([...trusted, ...rootCA])].filter(isCertActive); } diff --git a/src/common/utils/promise-exec.ts b/src/common/utils/promise-exec.ts index 8b9d5b72cc5e..b2d39ee11017 100644 --- a/src/common/utils/promise-exec.ts +++ b/src/common/utils/promise-exec.ts @@ -20,7 +20,6 @@ */ import * as util from "util"; -import { exec, execFile } from "child_process"; +import { execFile } from "child_process"; -export const promiseExec = util.promisify(exec); export const promiseExecFile = util.promisify(execFile); diff --git a/src/extensions/__tests__/extension-compatibility.test.ts b/src/extensions/__tests__/extension-compatibility.test.ts index 6bcac675019b..6a3dc0265d8a 100644 --- a/src/extensions/__tests__/extension-compatibility.test.ts +++ b/src/extensions/__tests__/extension-compatibility.test.ts @@ -19,29 +19,24 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { isCompatibleExtension } from "../extension-compatibility"; +import { rawIsCompatibleExtension } from "../extension-compatibility"; import { Console } from "console"; import { stdout, stderr } from "process"; import type { LensExtensionManifest } from "../lens-extension"; -import { appSemVer } from "../../common/vars"; +import { SemVer } from "semver"; console = new Console(stdout, stderr); describe("extension compatibility", () => { describe("appSemVer with no prerelease tag", () => { - beforeAll(() => { - appSemVer.major = 5; - appSemVer.minor = 0; - appSemVer.patch = 3; - appSemVer.prerelease = []; - }); + const isCompatibleExtension = rawIsCompatibleExtension(new SemVer("5.0.3")); it("has no extension comparator", () => { const manifest = { name: "extensionName", version: "0.0.1" }; expect(isCompatibleExtension(manifest)).toBe(false); }); - + it.each([ { comparator: "", @@ -83,19 +78,32 @@ describe("extension compatibility", () => { }); describe("appSemVer with prerelease tag", () => { - beforeAll(() => { - appSemVer.major = 5; - appSemVer.minor = 0; - appSemVer.patch = 3; - appSemVer.prerelease = ["beta", 3]; + const isCompatibleExtension = rawIsCompatibleExtension(new SemVer("5.0.3-beta.3")); + + it("^5.1.0 should work when lens' version is 5.1.0-latest.123456789", () => { + const comparer = rawIsCompatibleExtension(new SemVer("5.1.0-latest.123456789")); + + expect(comparer({ name: "extensionName", version: "0.0.1", engines: { lens: "^5.1.0" }})).toBe(true); }); - + + it("^5.1.0 should not when lens' version is 5.1.0-beta.1.123456789", () => { + const comparer = rawIsCompatibleExtension(new SemVer("5.1.0-beta.123456789")); + + expect(comparer({ name: "extensionName", version: "0.0.1", engines: { lens: "^5.1.0" }})).toBe(false); + }); + + it("^5.1.0 should not when lens' version is 5.1.0-alpha.1.123456789", () => { + const comparer = rawIsCompatibleExtension(new SemVer("5.1.0-alpha.123456789")); + + expect(comparer({ name: "extensionName", version: "0.0.1", engines: { lens: "^5.1.0" }})).toBe(false); + }); + it("has no extension comparator", () => { const manifest = { name: "extensionName", version: "0.0.1" }; expect(isCompatibleExtension(manifest)).toBe(false); }); - + it.each([ { comparator: "", @@ -130,9 +138,7 @@ describe("extension compatibility", () => { expected: false, }, ])("extension comparator test: %p", ({ comparator, expected }) => { - const manifest: LensExtensionManifest = { name: "extensionName", version: "0.0.1", engines: { lens: comparator }}; - - expect(isCompatibleExtension(manifest)).toBe(expected); + expect(isCompatibleExtension({ name: "extensionName", version: "0.0.1", engines: { lens: comparator }})).toBe(expected); }); }); }); diff --git a/src/extensions/extension-compatibility.ts b/src/extensions/extension-compatibility.ts index 3d958072dca8..ba5dcf2e1a31 100644 --- a/src/extensions/extension-compatibility.ts +++ b/src/extensions/extension-compatibility.ts @@ -19,19 +19,47 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import semver from "semver"; +import semver, { SemVer } from "semver"; import { appSemVer, isProduction } from "../common/vars"; import type { LensExtensionManifest } from "./lens-extension"; -export function isCompatibleExtension(manifest: LensExtensionManifest): boolean { - if (manifest.engines?.lens) { - /* include Lens's prerelease tag in the matching so the extension's compatibility is not limited by it */ - return semver.satisfies(appSemVer, manifest.engines.lens, { includePrerelease: true }); +export function rawIsCompatibleExtension(version: SemVer): (manifest: LensExtensionManifest) => boolean { + const { major, minor, patch, prerelease: oldPrelease } = version; + let prerelease = ""; + + if (oldPrelease.length > 0) { + const [first] = oldPrelease; + + if (first === "alpha" || first === "beta" || first === "rc") { + /** + * Strip the build IDs and "latest" prerelease tag as that is not really + * a part of API version + */ + prerelease = `-${oldPrelease.slice(0, 2).join(".")}`; + } } - return false; + /** + * We unfortunately have to format as string because the constructor only + * takes an instance or a string. + */ + const strippedVersion = new SemVer(`${major}.${minor}.${patch}${prerelease}`, { includePrerelease: true }); + + return (manifest: LensExtensionManifest): boolean => { + if (manifest.engines?.lens) { + /** + * include Lens's prerelease tag in the matching so the extension's + * compatibility is not limited by it + */ + return semver.satisfies(strippedVersion, manifest.engines.lens, { includePrerelease: true }); + } + + return false; + }; } +export const isCompatibleExtension = rawIsCompatibleExtension(appSemVer); + export function isCompatibleBundledExtension(manifest: LensExtensionManifest): boolean { return !isProduction || manifest.version === appSemVer.raw; } diff --git a/src/main/cluster-manager.ts b/src/main/cluster-manager.ts index ee1707b72668..da662710f7de 100644 --- a/src/main/cluster-manager.ts +++ b/src/main/cluster-manager.ts @@ -136,11 +136,16 @@ export class ClusterManager extends Singleton { entity.spec.metrics.prometheus = prometheus; } - // Only set the icon if the preference is set. If the preference is not set - // then let the source determine if a cluster has an icon. if (cluster.preferences.icon) { entity.spec.icon ??= {}; entity.spec.icon.src = cluster.preferences.icon; + } else if (cluster.preferences.icon === null) { + /** + * NOTE: only clear the icon if set to `null` by ClusterIconSettings. + * We can then also clear that value too + */ + entity.spec.icon = undefined; + cluster.preferences.icon = undefined; } catalogEntityRegistry.items.splice(index, 1, entity); @@ -177,7 +182,8 @@ export class ClusterManager extends Singleton { } } - @action syncClustersFromCatalog(entities: KubernetesCluster[]) { + @action + protected syncClustersFromCatalog(entities: KubernetesCluster[]) { for (const entity of entities) { const cluster = this.store.getById(entity.metadata.uid); diff --git a/src/main/helm/helm-release-manager.ts b/src/main/helm/helm-release-manager.ts index 4f96562e5d13..5a5f1e6ef282 100644 --- a/src/main/helm/helm-release-manager.ts +++ b/src/main/helm/helm-release-manager.ts @@ -22,161 +22,229 @@ import * as tempy from "tempy"; import fse from "fs-extra"; import * as yaml from "js-yaml"; -import { promiseExec } from "../../common/utils/promise-exec"; +import { promiseExecFile } from "../../common/utils/promise-exec"; import { helmCli } from "./helm-cli"; -import type { Cluster } from "../cluster"; import { toCamelCase } from "../../common/utils/camelCase"; +import type { BaseEncodingOptions } from "fs"; +import { execFile, ExecFileOptions } from "child_process"; -export async function listReleases(pathToKubeconfig: string, namespace?: string) { - const helm = await helmCli.binaryPath(); - const namespaceFlag = namespace ? `-n ${namespace}` : "--all-namespaces"; +async function execHelm(args: string[], options?: BaseEncodingOptions & ExecFileOptions): Promise { + const helmCliPath = await helmCli.binaryPath(); try { - const { stdout } = await promiseExec(`"${helm}" ls --output json ${namespaceFlag} --kubeconfig ${pathToKubeconfig}`); - const output = JSON.parse(stdout); - - if (output.length == 0) { - return output; - } - output.forEach((release: any, index: number) => { - output[index] = toCamelCase(release); - }); + const { stdout } = await promiseExecFile(helmCliPath, args, options); - return output; + return stdout; } catch (error) { throw error?.stderr || error; } } +export async function listReleases(pathToKubeconfig: string, namespace?: string): Promise[]> { + const args = [ + "ls", + "--output", "json", + ]; -export async function installChart(chart: string, values: any, name: string | undefined, namespace: string, version: string, pathToKubeconfig: string) { - const helm = await helmCli.binaryPath(); - const fileName = tempy.file({ name: "values.yaml" }); + if (namespace) { + args.push("-n", namespace); + } else { + args.push("--all-namespaces"); + } - await fse.writeFile(fileName, yaml.dump(values)); + args.push("--kubeconfig", pathToKubeconfig); - try { - let generateName = ""; + const output = JSON.parse(await execHelm(args)); + + if (!Array.isArray(output) || output.length == 0) { + return []; + } - if (!name) { - generateName = "--generate-name"; - name = ""; - } - const { stdout } = await promiseExec(`"${helm}" install ${name} ${chart} --version ${version} -f ${fileName} --namespace ${namespace} --kubeconfig ${pathToKubeconfig} ${generateName}`); - const releaseName = stdout.split("\n")[0].split(" ")[1].trim(); + return output.map(toCamelCase); +} + + +export async function installChart(chart: string, values: any, name: string | undefined = "", namespace: string, version: string, kubeconfigPath: string) { + const valuesFilePath = tempy.file({ name: "values.yaml" }); + + await fse.writeFile(valuesFilePath, yaml.dump(values)); + + const args = ["install"]; + + if (name) { + args.push(name); + } + + args.push( + chart, + "--version", version, + "--values", valuesFilePath, + "--namespace", namespace, + "--kubeconfig", kubeconfigPath, + ); + + if (!name) { + args.push("--generate-name"); + } + + try { + const output = await execHelm(args); + const releaseName = output.split("\n")[0].split(" ")[1].trim(); return { - log: stdout, + log: output, release: { name: releaseName, namespace, }, }; - } catch (error) { - throw error?.stderr || error; } finally { - await fse.unlink(fileName); + await fse.unlink(valuesFilePath); } } -export async function upgradeRelease(name: string, chart: string, values: any, namespace: string, version: string, cluster: Cluster) { - const helm = await helmCli.binaryPath(); - const fileName = tempy.file({ name: "values.yaml" }); +export async function upgradeRelease(name: string, chart: string, values: any, namespace: string, version: string, kubeconfigPath: string, kubectlPath: string) { + const valuesFilePath = tempy.file({ name: "values.yaml" }); - await fse.writeFile(fileName, yaml.dump(values)); + await fse.writeFile(valuesFilePath, yaml.dump(values)); + + const args = [ + "upgrade", + name, + chart, + "--version", version, + "--values", valuesFilePath, + "--namespace", namespace, + "--kubeconfig", kubeconfigPath, + ]; try { - const proxyKubeconfig = await cluster.getProxyKubeconfigPath(); - const { stdout } = await promiseExec(`"${helm}" upgrade ${name} ${chart} --version ${version} -f ${fileName} --namespace ${namespace} --kubeconfig ${proxyKubeconfig}`); + const output = await execHelm(args); return { - log: stdout, - release: getRelease(name, namespace, cluster), + log: output, + release: getRelease(name, namespace, kubeconfigPath, kubectlPath), }; - } catch (error) { - throw error?.stderr || error; } finally { - await fse.unlink(fileName); + await fse.unlink(valuesFilePath); } } -export async function getRelease(name: string, namespace: string, cluster: Cluster) { - try { - const helm = await helmCli.binaryPath(); - const proxyKubeconfig = await cluster.getProxyKubeconfigPath(); +export async function getRelease(name: string, namespace: string, kubeconfigPath: string, kubectlPath: string) { + const args = [ + "status", + name, + "--namespace", namespace, + "--kubeconfig", kubeconfigPath, + "--output", "json", + ]; - const { stdout } = await promiseExec(`"${helm}" status ${name} --output json --namespace ${namespace} --kubeconfig ${proxyKubeconfig}`, { - maxBuffer: 32 * 1024 * 1024 * 1024, // 32 MiB - }); - const release = JSON.parse(stdout); + const release = JSON.parse(await execHelm(args, { + maxBuffer: 32 * 1024 * 1024 * 1024, // 32 MiB + })); - release.resources = await getResources(name, namespace, cluster); + release.resources = await getResources(name, namespace, kubeconfigPath, kubectlPath); - return release; - } catch (error) { - throw error?.stderr || error; - } + return release; } -export async function deleteRelease(name: string, namespace: string, pathToKubeconfig: string) { - try { - const helm = await helmCli.binaryPath(); - const { stdout } = await promiseExec(`"${helm}" delete ${name} --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`); - - return stdout; - } catch (error) { - throw error?.stderr || error; - } +export async function deleteRelease(name: string, namespace: string, kubeconfigPath: string) { + return execHelm([ + "delete", + name, + "--namespace", namespace, + "--kubeconfig", kubeconfigPath, + ]); } interface GetValuesOptions { namespace: string; all?: boolean; - pathToKubeconfig: string; + kubeconfigPath: string; } -export async function getValues(name: string, { namespace, all = false, pathToKubeconfig }: GetValuesOptions) { - try { - const helm = await helmCli.binaryPath(); - const { stdout } = await promiseExec(`"${helm}" get values ${name} ${all ? "--all" : ""} --output yaml --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`); +export async function getValues(name: string, { namespace, all = false, kubeconfigPath }: GetValuesOptions) { + const args = [ + "get", + "values", + name, + ]; - return stdout; - } catch (error) { - throw error?.stderr || error; + if (all) { + args.push("--all"); } -} -export async function getHistory(name: string, namespace: string, pathToKubeconfig: string) { - try { - const helm = await helmCli.binaryPath(); - const { stdout } = await promiseExec(`"${helm}" history ${name} --output json --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`); + args.push( + "--output", "yaml", + "--namespace", namespace, + "--kubeconfig", kubeconfigPath, + ); - return JSON.parse(stdout); - } catch (error) { - throw error?.stderr || error; - } + return execHelm(args); } -export async function rollback(name: string, namespace: string, revision: number, pathToKubeconfig: string) { - try { - const helm = await helmCli.binaryPath(); - const { stdout } = await promiseExec(`"${helm}" rollback ${name} ${revision} --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`); +export async function getHistory(name: string, namespace: string, kubeconfigPath: string) { + return JSON.parse(await execHelm([ + "history", + name, + "--output", "json", + "--namespace", namespace, + "--kubeconfig", kubeconfigPath, + ])); +} - return stdout; - } catch (error) { - throw error?.stderr || error; - } +export async function rollback(name: string, namespace: string, revision: number, kubeconfigPath: string) { + return JSON.parse(await execHelm([ + "rollback", + name, + "--namespace", namespace, + "--kubeconfig", kubeconfigPath, + ])); } -async function getResources(name: string, namespace: string, cluster: Cluster) { - try { - const helm = await helmCli.binaryPath(); - const kubectl = await cluster.ensureKubectl(); - const kubectlPath = await kubectl.getPath(); - const pathToKubeconfig = await cluster.getProxyKubeconfigPath(); - const { stdout } = await promiseExec(`"${helm}" get manifest ${name} --namespace ${namespace} --kubeconfig ${pathToKubeconfig} | "${kubectlPath}" get -n ${namespace} --kubeconfig ${pathToKubeconfig} -f - -o=json`); +async function getResources(name: string, namespace: string, kubeconfigPath: string, kubectlPath: string) { + const helmArgs = [ + "get", + "manifest", + name, + "--namespace", namespace, + "--kubeconfig", kubeconfigPath, + ]; + const kubectlArgs = [ + "get", + "--namespace", namespace, + "--kubeconfig", kubeconfigPath, + "-f", "-", + "--output", "json", + ]; - return JSON.parse(stdout).items; + try { + const helmOutput = await execHelm(helmArgs); + + return new Promise((resolve, reject) => { + let stdout = ""; + let stderr = ""; + const kubectl = execFile(kubectlPath, kubectlArgs); + + kubectl + .on("exit", (code, signal) => { + if (typeof code === "number") { + if (code === 0) { + resolve(JSON.parse(stdout).items); + } else { + reject(stderr); + } + } else { + reject(new Error(`Kubectl exited with signal ${signal}`)); + } + }) + .on("error", reject); + + kubectl.stderr.on("data", output => stderr += output); + kubectl.stdout.on("data", output => stdout += output); + kubectl.stdin.write(helmOutput); + kubectl.stdin.end(); + }); } catch { return []; } diff --git a/src/main/helm/helm-repo-manager.ts b/src/main/helm/helm-repo-manager.ts index 1c36bef2bef6..f8edc12e008d 100644 --- a/src/main/helm/helm-repo-manager.ts +++ b/src/main/helm/helm-repo-manager.ts @@ -20,13 +20,14 @@ */ import yaml from "js-yaml"; -import { readFile } from "fs-extra"; -import { promiseExec } from "../../common/utils/promise-exec"; +import { BaseEncodingOptions, readFile } from "fs-extra"; +import { promiseExecFile } from "../../common/utils/promise-exec"; import { helmCli } from "./helm-cli"; import { Singleton } from "../../common/utils/singleton"; import { customRequestPromise } from "../../common/request"; import orderBy from "lodash/orderBy"; import logger from "../logger"; +import type { ExecFileOptions } from "child_process"; export type HelmEnv = Record & { HELM_REPOSITORY_CACHE?: string; @@ -49,6 +50,18 @@ export interface HelmRepo { password?: string, } +async function execHelm(args: string[], options?: BaseEncodingOptions & ExecFileOptions): Promise { + const helmCliPath = await helmCli.binaryPath(); + + try { + const { stdout } = await promiseExecFile(helmCliPath, args, options); + + return stdout; + } catch (error) { + throw error?.stderr || error; + } +} + export class HelmRepoManager extends Singleton { protected repos: HelmRepo[]; protected helmEnv: HelmEnv; @@ -77,11 +90,8 @@ export class HelmRepoManager extends Singleton { } protected static async parseHelmEnv() { - const helm = await helmCli.binaryPath(); - const { stdout } = await promiseExec(`"${helm}" env`).catch((error) => { - throw(error.stderr); - }); - const lines = stdout.split(/\r?\n/); // split by new line feed + const output = await execHelm(["env"]); + const lines = output.split(/\r?\n/); // split by new line feed const env: HelmEnv = {}; lines.forEach((line: string) => { @@ -135,57 +145,73 @@ export class HelmRepoManager extends Singleton { cacheFilePath: `${this.helmEnv.HELM_REPOSITORY_CACHE}/${repo.name}-index.yaml`, })); } catch (error) { - logger.error(`[HELM]: repositories listing error "${error}"`); + logger.error(`[HELM]: repositories listing error`, error); return []; } } public static async update() { - const helm = await helmCli.binaryPath(); - const { stdout } = await promiseExec(`"${helm}" repo update`).catch((error) => { - return { stdout: error.stdout }; - }); - - return stdout; + return execHelm([ + "repo", + "update", + ]); } public static async addRepo({ name, url }: HelmRepo) { logger.info(`[HELM]: adding repo "${name}" from ${url}`); - const helm = await helmCli.binaryPath(); - const { stdout } = await promiseExec(`"${helm}" repo add ${name} ${url}`).catch((error) => { - throw(error.stderr); - }); - return stdout; + return execHelm([ + "repo", + "add", + name, + url, + ]); } - public static async addCustomRepo(repoAttributes : HelmRepo) { - logger.info(`[HELM]: adding repo "${repoAttributes.name}" from ${repoAttributes.url}`); - const helm = await helmCli.binaryPath(); + public static async addCustomRepo({ 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"); + } - const insecureSkipTlsVerify = repoAttributes.insecureSkipTlsVerify ? " --insecure-skip-tls-verify" : ""; - const username = repoAttributes.username ? ` --username "${repoAttributes.username}"` : ""; - const password = repoAttributes.password ? ` --password "${repoAttributes.password}"` : ""; - const caFile = repoAttributes.caFile ? ` --ca-file "${repoAttributes.caFile}"` : ""; - const keyFile = repoAttributes.keyFile ? ` --key-file "${repoAttributes.keyFile}"` : ""; - const certFile = repoAttributes.certFile ? ` --cert-file "${repoAttributes.certFile}"` : ""; + if (username) { + args.push("--username", username); + } - const addRepoCommand = `"${helm}" repo add ${repoAttributes.name} ${repoAttributes.url}${insecureSkipTlsVerify}${username}${password}${caFile}${keyFile}${certFile}`; - const { stdout } = await promiseExec(addRepoCommand).catch((error) => { - throw(error.stderr); - }); + if (password) { + args.push("--password", password); + } - return stdout; + 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 static async removeRepo({ name, url }: HelmRepo): Promise { - logger.info(`[HELM]: removing repo "${name}" from ${url}`); - const helm = await helmCli.binaryPath(); - const { stdout } = await promiseExec(`"${helm}" repo remove ${name}`).catch((error) => { - throw(error.stderr); - }); + logger.info(`[HELM]: removing repo ${name} (${url})`); - return stdout; + return execHelm([ + "repo", + "remove", + name, + ]); } } diff --git a/src/main/helm/helm-service.ts b/src/main/helm/helm-service.ts index 2e86e3651300..4d4637b3b1da 100644 --- a/src/main/helm/helm-service.ts +++ b/src/main/helm/helm-service.ts @@ -65,13 +65,19 @@ class HelmService { public async listReleases(cluster: Cluster, namespace: string = null) { 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, cluster); + return getRelease(releaseName, namespace, kubeconfigPath, kubectlPath); } public async getReleaseValues(releaseName: string, { cluster, namespace, all }: GetReleaseValuesArgs) { @@ -79,7 +85,7 @@ class HelmService { logger.debug("Fetch release values"); - return getValues(releaseName, { namespace, all, pathToKubeconfig }); + return getValues(releaseName, { namespace, all, kubeconfigPath: pathToKubeconfig }); } public async getReleaseHistory(cluster: Cluster, releaseName: string, namespace: string) { @@ -99,9 +105,13 @@ class HelmService { } public async updateRelease(cluster: Cluster, releaseName: string, namespace: string, data: { chart: string; values: {}; version: string }) { + 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, cluster); + return upgradeRelease(releaseName, data.chart, data.values, namespace, data.version, proxyKubeconfig, kubectlPath); } public async rollback(cluster: Cluster, releaseName: string, namespace: string, revision: number) { diff --git a/src/main/kubectl.ts b/src/main/kubectl.ts index dbe450608293..2ac1831c41a0 100644 --- a/src/main/kubectl.ts +++ b/src/main/kubectl.ts @@ -21,7 +21,7 @@ import path from "path"; import fs from "fs"; -import { promiseExec } from "../common/utils/promise-exec"; +import { promiseExecFile } from "../common/utils/promise-exec"; import logger from "./logger"; import { ensureDir, pathExists } from "fs-extra"; import * as lockFile from "proper-lockfile"; @@ -199,7 +199,12 @@ export class Kubectl { if (exists) { try { - const { stdout } = await promiseExec(`"${path}" version --client=true -o json`); + const args = [ + "version", + "--client", "true", + "--output", "json", + ]; + const { stdout } = await promiseExecFile(path, args); const output = JSON.parse(stdout); if (!checkVersion) { diff --git a/src/main/lens-proxy.ts b/src/main/lens-proxy.ts index bfaaab27effa..a9982d5cb727 100644 --- a/src/main/lens-proxy.ts +++ b/src/main/lens-proxy.ts @@ -50,6 +50,24 @@ export function isLongRunningRequest(reqUrl: string) { return getBoolean(url.searchParams, watchParam) || getBoolean(url.searchParams, followParam); } +/** + * This is the list of ports that chrome considers unsafe to allow HTTP + * conntections to. Because they are the standard ports for processes that are + * too forgiving in the connection types they accept. + * + * If we get one of these ports, the easiest thing to do is to just try again. + * + * Source: https://chromium.googlesource.com/chromium/src.git/+/refs/heads/main/net/base/port_util.cc + */ +const disallowedPorts = new Set([ + 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, + 87, 95, 101, 102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, + 139, 143, 161, 179, 389, 427, 465, 512, 513, 514, 515, 526, 530, 531, 532, + 540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993, 995, 1719, 1720, 1723, + 2049, 3659, 4045, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6697, + 10080, +]); + export class LensProxy extends Singleton { protected origin: string; protected proxyServer: http.Server; @@ -91,12 +109,13 @@ export class LensProxy extends Singleton { } /** - * Starts the lens proxy. - * @resolves After the server is listening - * @rejects if there is an error before that happens + * Starts to listen on an OS provided port. Will reject if the server throws + * an error. + * + * Resolves with the port number that was picked */ - listen(): Promise { - return new Promise((resolve, reject) => { + private attemptToListen(): Promise { + return new Promise((resolve, reject) => { this.proxyServer.listen(0, "127.0.0.1"); this.proxyServer @@ -113,7 +132,7 @@ export class LensProxy extends Singleton { this.port = port; appEventBus.emit({ name: "lens-proxy", action: "listen", params: { port }}); - resolve(); + resolve(port); }) .once("error", (error) => { logger.info(`[LENS-PROXY]: Proxy server failed to start: ${error}`); @@ -122,8 +141,40 @@ export class LensProxy extends Singleton { }); } + /** + * Starts the lens proxy. + * @resolves After the server is listening on a good port + * @rejects if there is an error before that happens + */ + async listen(): Promise { + const seenPorts = new Set(); + + while(true) { + this.proxyServer?.close(); + const port = await this.attemptToListen(); + + if (!disallowedPorts.has(port)) { + // We didn't get a port that would result in an ERR_UNSAFE_PORT error, use it + return; + } + + logger.warn(`[LENS-PROXY]: Proxy server has with port known to be considered unsafe to connect to by chrome, restarting...`); + + if (seenPorts.has(port)) { + /** + * Assume that if we have seen the port before, then the OS has looped + * through all the ports possible and we will not be able to get a safe + * port. + */ + throw new Error("Failed to start LensProxy due to seeing too many unsafe ports. Please restart Lens."); + } else { + seenPorts.add(port); + } + } + } + close() { - logger.info("Closing proxy server"); + logger.info("[LENS-PROXY]: Closing server"); this.proxyServer.close(); this.closed = true; } diff --git a/src/main/prometheus/operator.ts b/src/main/prometheus/operator.ts index 28e65fd96f7d..4e326ad75374 100644 --- a/src/main/prometheus/operator.ts +++ b/src/main/prometheus/operator.ts @@ -38,7 +38,7 @@ export class PrometheusOperator extends PrometheusProvider { case "cluster": switch (queryName) { case "memoryUsage": - return `sum(node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes))`.replace(/_bytes/g, `_bytes{node=~"${opts.nodes}"}`); + return `sum(node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes))`.replace(/_bytes/g, `_bytes * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"}`); case "workloadMemoryUsage": return `sum(container_memory_working_set_bytes{container!="", instance=~"${opts.nodes}"}) by (component)`; case "memoryRequests": @@ -50,7 +50,7 @@ export class PrometheusOperator extends PrometheusProvider { case "memoryAllocatableCapacity": return `sum(kube_node_status_allocatable{node=~"${opts.nodes}", resource="memory"})`; case "cpuUsage": - return `sum(rate(node_cpu_seconds_total{node=~"${opts.nodes}", mode=~"user|system"}[${this.rateAccuracy}]))`; + return `sum(rate(node_cpu_seconds_total{mode=~"user|system"}[${this.rateAccuracy}])* on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"})`; case "cpuRequests": return `sum(kube_pod_container_resource_requests{node=~"${opts.nodes}", resource="cpu"})`; case "cpuLimits": @@ -66,31 +66,31 @@ export class PrometheusOperator extends PrometheusProvider { case "podAllocatableCapacity": return `sum(kube_node_status_allocatable{node=~"${opts.nodes}", resource="pods"})`; case "fsSize": - return `sum(node_filesystem_size_bytes{node=~"${opts.nodes}", mountpoint="/"}) by (node)`; + return `sum(node_filesystem_size_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"})`; case "fsUsage": - return `sum(node_filesystem_size_bytes{node=~"${opts.nodes}", mountpoint="/"} - node_filesystem_avail_bytes{node=~"${opts.nodes}", mountpoint="/"}) by (node)`; + return `sum(node_filesystem_size_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"} - node_filesystem_avail_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"})`; } break; case "nodes": switch (queryName) { case "memoryUsage": - return `sum (node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes)) by (node)`; + return `sum((node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes)) * on (pod, namespace) group_left(node) kube_pod_info) by (node)`; case "workloadMemoryUsage": - return `sum(container_memory_working_set_bytes{container!=""}) by (node)`; + return `sum(container_memory_working_set_bytes{container!="POD", container!=""}) by (node)`; case "memoryCapacity": return `sum(kube_node_status_capacity{resource="memory"}) by (node)`; case "memoryAllocatableCapacity": return `sum(kube_node_status_allocatable{resource="memory"}) by (node)`; case "cpuUsage": - return `sum(rate(node_cpu_seconds_total{mode=~"user|system"}[${this.rateAccuracy}])) by(node)`; + return `sum(rate(node_cpu_seconds_total{mode=~"user|system"}[${this.rateAccuracy}]) * on (pod, namespace) group_left(node) kube_pod_info) by (node)`; case "cpuCapacity": return `sum(kube_node_status_allocatable{resource="cpu"}) by (node)`; case "cpuAllocatableCapacity": return `sum(kube_node_status_allocatable{resource="cpu"}) by (node)`; case "fsSize": - return `sum(node_filesystem_size_bytes{mountpoint="/"}) by (node)`; + return `sum(node_filesystem_size_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info) by (node)`; case "fsUsage": - return `sum(node_filesystem_size_bytes{mountpoint="/"} - node_filesystem_avail_bytes{mountpoint="/"}) by (node)`; + return `sum((node_filesystem_size_bytes{mountpoint="/"} - node_filesystem_avail_bytes{mountpoint="/"}) * on (pod, namespace) group_left(node) kube_pod_info) by (node)`; } break; case "pods": diff --git a/src/main/routes/metrics-route.ts b/src/main/routes/metrics-route.ts index ec53c1a46b09..c323c06be957 100644 --- a/src/main/routes/metrics-route.ts +++ b/src/main/routes/metrics-route.ts @@ -46,7 +46,7 @@ async function loadMetrics(promQueries: string[], cluster: Cluster, prometheusPa return await getMetrics(cluster, prometheusPath, { query, ...queryParams }); } catch (error) { if (lastAttempt || (error?.statusCode >= 400 && error?.statusCode < 500)) { - logger.error("[Metrics]: metrics not available", error); + logger.error("[Metrics]: metrics not available", error?.response ? error.response?.body : error); throw new Error("Metrics not available"); } diff --git a/src/renderer/components/+network-policies/__tests__/network-policy-details.test.tsx b/src/renderer/components/+network-policies/__tests__/network-policy-details.test.tsx index ff6adb8cefa2..2b6684300f68 100644 --- a/src/renderer/components/+network-policies/__tests__/network-policy-details.test.tsx +++ b/src/renderer/components/+network-policies/__tests__/network-policy-details.test.tsx @@ -53,4 +53,19 @@ describe("NetworkPolicyDetails", () => { expect(await findByTestId(container, "egress-0")).toBeInstanceOf(HTMLElement); expect(await findByText(container, "foo: bar")).toBeInstanceOf(HTMLElement); }); + + it("should not crash if egress nodeSelector doesn't have matchLabels", async () => { + const spec: NetworkPolicySpec = { + egress: [{ + to: [{ + namespaceSelector: {}, + }], + }], + podSelector: {}, + }; + const policy = new NetworkPolicy({ metadata: {} as any, spec } as any); + const { container } = render(); + + expect(container).toBeInstanceOf(HTMLElement); + }); }); diff --git a/src/renderer/components/+network-policies/network-policy-details.module.css b/src/renderer/components/+network-policies/network-policy-details.module.css index 50be1a602466..a3c06f8616a9 100644 --- a/src/renderer/components/+network-policies/network-policy-details.module.css +++ b/src/renderer/components/+network-policies/network-policy-details.module.css @@ -29,4 +29,13 @@ padding-bottom: 16px; } } + + ul.policySelectorList { + list-style: disc; + } + + .policySelectorList ul { + list-style: circle; + list-style-position: inside; + } } diff --git a/src/renderer/components/+network-policies/network-policy-details.tsx b/src/renderer/components/+network-policies/network-policy-details.tsx index e5632ec30d07..ff870bc2cccb 100644 --- a/src/renderer/components/+network-policies/network-policy-details.tsx +++ b/src/renderer/components/+network-policies/network-policy-details.tsx @@ -23,13 +23,15 @@ import styles from "./network-policy-details.module.css"; import React from "react"; import { DrawerItem, DrawerTitle } from "../drawer"; -import { IPolicyIpBlock, IPolicySelector, NetworkPolicy, NetworkPolicyPeer, NetworkPolicyPort } from "../../../common/k8s-api/endpoints/network-policy.api"; +import { IPolicyIpBlock, NetworkPolicy, NetworkPolicyPeer, NetworkPolicyPort } from "../../../common/k8s-api/endpoints/network-policy.api"; import { Badge } from "../badge"; import { SubTitle } from "../layout/sub-title"; import { observer } from "mobx-react"; import type { KubeObjectDetailsProps } from "../kube-object-details"; import { KubeObjectMeta } from "../kube-object-meta"; import logger from "../../../common/logger"; +import type { LabelMatchExpression, LabelSelector } from "../../../common/k8s-api/kube-object"; +import { isEmpty } from "lodash"; interface Props extends KubeObjectDetailsProps { } @@ -60,20 +62,57 @@ export class NetworkPolicyDetails extends React.Component { ); } - renderIPolicySelector(name: string, selector: IPolicySelector | undefined) { + renderMatchLabels(matchLabels: Record | undefined) { + if (!matchLabels) { + return null; + } + + return Object.entries(matchLabels) + .map(([key, value]) =>
  • {key}: {value}
  • ); + } + + renderMatchExpressions(matchExpressions: LabelMatchExpression[] | undefined) { + if (!matchExpressions) { + return null; + } + + return matchExpressions.map(expr => { + switch (expr.operator) { + case "DoesNotExist": + case "Exists": + return
  • {expr.key} ({expr.operator})
  • ; + case "In": + case "NotIn": + return ( +
  • + {expr.key}({expr.operator}) +
      + {expr.values.map((value, index) =>
    • {value}
    • )} +
    +
  • + ); + } + }); + } + + renderIPolicySelector(name: string, selector: LabelSelector | undefined) { if (!selector) { return null; } + const { matchLabels, matchExpressions } = selector; + return ( - { - Object - .entries(selector.matchLabels) - .map(data => data.join(": ")) - .join(", ") - || "(empty)" - } +
      + {this.renderMatchLabels(matchLabels)} + {this.renderMatchExpressions(matchExpressions)} + { + (isEmpty(matchLabels) && isEmpty(matchExpressions)) && ( +
    • (empty)
    • + ) + } +
    ); } diff --git a/src/renderer/components/+preferences/add-helm-repo-dialog.tsx b/src/renderer/components/+preferences/add-helm-repo-dialog.tsx index d6760fa561a8..608c33bad27d 100644 --- a/src/renderer/components/+preferences/add-helm-repo-dialog.tsx +++ b/src/renderer/components/+preferences/add-helm-repo-dialog.tsx @@ -121,7 +121,7 @@ export class AddHelmRepoDialog extends React.Component {
    this.setFilepath(fileType, v)} @@ -172,7 +172,7 @@ export class AddHelmRepoDialog extends React.Component { close={this.close} > - {this.addCustomRepo();}}> + this.addCustomRepo()}>
    = { - getNotificationMessage(copiedText: string) { - return

    Copied to clipboard: {copiedText}

    ; - }, -}; - -export class Clipboard extends React.Component { - static displayName = "Clipboard"; - static defaultProps = defaultProps as object; - - get rootElem(): HTMLElement { - // eslint-disable-next-line react/no-find-dom-node - return findDOMNode(this) as HTMLElement; - } - - get rootReactElem(): React.ReactElement> { - return React.Children.only(this.props.children) as React.ReactElement; - } - - @boundMethod - onClick(evt: React.MouseEvent) { - if (this.rootReactElem.props.onClick) { - this.rootReactElem.props.onClick(evt); // pass event to children-root-element if any - } - const { showNotification, resetSelection, getNotificationMessage, cssSelectorLimit } = this.props; - const contentElem = this.rootElem.querySelector(cssSelectorLimit) || this.rootElem; - - if (contentElem) { - const { copiedText, copied } = copyToClipboard(contentElem, { resetSelection }); - - if (copied && showNotification) { - Notifications.ok(getNotificationMessage(copiedText)); - } - } - } - - render() { - try { - const rootElem = this.rootReactElem; - - return React.cloneElement(rootElem, { - className: cssNames(Clipboard.displayName, rootElem.props.className), - onClick: this.onClick, - }); - } catch (err) { - logger.error(`Invalid usage components/CopyToClick usage. Children must contain root html element.`, { err: String(err) }); - - return this.rootReactElem; - } - } -} diff --git a/src/renderer/components/cluster-settings/components/cluster-icon-settings.tsx b/src/renderer/components/cluster-settings/components/cluster-icon-settings.tsx index 980ba886333e..f301411e3b80 100644 --- a/src/renderer/components/cluster-settings/components/cluster-icon-settings.tsx +++ b/src/renderer/components/cluster-settings/components/cluster-icon-settings.tsx @@ -65,7 +65,11 @@ export class ClusterIconSetting extends React.Component { } clearIcon() { - this.props.cluster.preferences.icon = undefined; + /** + * NOTE: this needs to be `null` rather than `undefined` so that we can + * tell the difference between it not being there and being cleared. + */ + this.props.cluster.preferences.icon = null; } @boundMethod diff --git a/src/renderer/components/dialog/logs-dialog.tsx b/src/renderer/components/dialog/logs-dialog.tsx index 83f685ba5b38..4299129466bc 100644 --- a/src/renderer/components/dialog/logs-dialog.tsx +++ b/src/renderer/components/dialog/logs-dialog.tsx @@ -24,10 +24,10 @@ import "./logs-dialog.scss"; import React from "react"; import { Dialog, DialogProps } from "../dialog"; import { Wizard, WizardStep } from "../wizard"; -import { copyToClipboard } from "../../utils"; import { Notifications } from "../notifications"; import { Button } from "../button"; import { Icon } from "../icon"; +import { clipboard } from "electron"; // todo: make as external BrowserWindow (?) @@ -40,9 +40,8 @@ export class LogsDialog extends React.Component { public logsElem: HTMLElement; copyToClipboard = () => { - if (copyToClipboard(this.logsElem)) { - Notifications.ok(`Logs copied to clipboard.`); - } + clipboard.writeText(this.props.logs); + Notifications.ok(`Logs copied to clipboard.`); }; render() { diff --git a/src/renderer/components/kubeconfig-dialog/kubeconfig-dialog.tsx b/src/renderer/components/kubeconfig-dialog/kubeconfig-dialog.tsx index 8c9f6a44d1d5..02a240043a70 100644 --- a/src/renderer/components/kubeconfig-dialog/kubeconfig-dialog.tsx +++ b/src/renderer/components/kubeconfig-dialog/kubeconfig-dialog.tsx @@ -25,7 +25,7 @@ import { makeObservable, observable } from "mobx"; import { observer } from "mobx-react"; import yaml from "js-yaml"; import type { ServiceAccount } from "../../../common/k8s-api/endpoints"; -import { copyToClipboard, saveFileDialog } from "../../utils"; +import { saveFileDialog } from "../../utils"; import { Button } from "../button"; import { Dialog, DialogProps } from "../dialog"; import { Icon } from "../icon"; @@ -33,6 +33,7 @@ import { Notifications } from "../notifications"; import { Wizard, WizardStep } from "../wizard"; import { apiBase } from "../../api"; import { MonacoEditor } from "../monaco-editor"; +import { clipboard } from "electron"; interface IKubeconfigDialogData { title?: React.ReactNode; @@ -49,7 +50,6 @@ const dialogState = observable.object({ @observer export class KubeConfigDialog extends React.Component { - @observable.ref configTextArea: HTMLTextAreaElement; // required for coping config text @observable config = ""; // parsed kubeconfig in yaml format constructor(props: Props) { @@ -89,9 +89,8 @@ export class KubeConfigDialog extends React.Component { } copyToClipboard = () => { - if (this.config && copyToClipboard(this.configTextArea)) { - Notifications.ok("Config copied to clipboard"); - } + clipboard.writeText(this.config); + Notifications.ok("Config copied to clipboard"); }; download = () => { @@ -131,11 +130,6 @@ export class KubeConfigDialog extends React.Component { className={styles.editor} value={yamlConfig} /> -