From ce995f3debde8ed539c5b6e724f4d0adf35beb2f Mon Sep 17 00:00:00 2001 From: Lauri Nevala Date: Thu, 22 Oct 2020 21:45:54 +0300 Subject: [PATCH] Implement cluster metadata detectors (#1106) Signed-off-by: Lauri Nevala --- src/common/base-store.ts | 8 +- src/common/cluster-ipc.ts | 2 +- src/common/cluster-store.ts | 5 ++ src/main/__test__/cluster.test.ts | 6 +- .../base-cluster-detector.ts | 33 ++++++++ .../cluster-detectors/cluster-id-detector.ts | 23 ++++++ .../cluster-detectors/detector-registry.ts | 45 ++++++++++ .../distribution-detector.ts | 80 ++++++++++++++++++ .../cluster-detectors/last-seen-detector.ts | 13 +++ .../cluster-detectors/nodes-count-detector.ts | 18 ++++ .../cluster-detectors/version-detector.ts | 17 ++++ src/main/cluster.ts | 82 ++++++++++--------- .../+cluster-settings/cluster-settings.tsx | 2 +- .../components/+cluster-settings/status.tsx | 8 +- 14 files changed, 293 insertions(+), 49 deletions(-) create mode 100644 src/main/cluster-detectors/base-cluster-detector.ts create mode 100644 src/main/cluster-detectors/cluster-id-detector.ts create mode 100644 src/main/cluster-detectors/detector-registry.ts create mode 100644 src/main/cluster-detectors/distribution-detector.ts create mode 100644 src/main/cluster-detectors/last-seen-detector.ts create mode 100644 src/main/cluster-detectors/nodes-count-detector.ts create mode 100644 src/main/cluster-detectors/version-detector.ts diff --git a/src/common/base-store.ts b/src/common/base-store.ts index f29d35d877..5261c604d6 100644 --- a/src/common/base-store.ts +++ b/src/common/base-store.ts @@ -90,13 +90,19 @@ export class BaseStore extends Singleton { if (ipcRenderer) { const callback = (event: IpcRendererEvent, model: T) => { logger.silly(`[STORE]: SYNC ${this.name} from main`, { model }); - this.onSync(model); + this.onSyncFromMain(model); }; ipcRenderer.on(this.syncChannel, callback); this.syncDisposers.push(() => ipcRenderer.off(this.syncChannel, callback)); } } + protected onSyncFromMain(model: T) { + this.applyWithoutSync(() => { + this.onSync(model) + }) + } + unregisterIpcListener() { ipcRenderer.removeAllListeners(this.syncChannel) } diff --git a/src/common/cluster-ipc.ts b/src/common/cluster-ipc.ts index 7a96b2e524..0f959aec41 100644 --- a/src/common/cluster-ipc.ts +++ b/src/common/cluster-ipc.ts @@ -30,7 +30,7 @@ export const clusterIpc = { channel: "cluster:refresh", handle: (clusterId: ClusterId) => { const cluster = clusterStore.getById(clusterId); - if (cluster) return cluster.refresh(); + if (cluster) return cluster.refresh({ refreshMetadata: true }) }, }), diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index b8549bbcc6..9ce9196217 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -20,6 +20,10 @@ export interface ClusterIconUpload { path: string; } +export interface ClusterMetadata { + [key: string]: string | number | boolean; +} + export interface ClusterStoreModel { activeCluster?: ClusterId; // last opened cluster clusters?: ClusterModel[] @@ -32,6 +36,7 @@ export interface ClusterModel { workspace?: WorkspaceId; contextName?: string; preferences?: ClusterPreferences; + metadata?: ClusterMetadata; kubeConfigPath: string; /** @deprecated */ diff --git a/src/main/__test__/cluster.test.ts b/src/main/__test__/cluster.test.ts index 52302c7064..52e984b195 100644 --- a/src/main/__test__/cluster.test.ts +++ b/src/main/__test__/cluster.test.ts @@ -38,6 +38,7 @@ import { getFreePort } from "../port"; import { V1ResourceAttributes } from "@kubernetes/client-node"; import { apiResources } from "../../common/rbac"; import request from "request-promise-native" +import { Kubectl } from "../kubectl"; const mockedRequest = request as jest.MockedFunction @@ -73,6 +74,7 @@ describe("create clusters", () => { }) } mockFs(mockOpts) + jest.spyOn(Kubectl.prototype, "ensureKubectl").mockReturnValue(Promise.resolve(true)) }) afterEach(() => { @@ -116,7 +118,7 @@ describe("create clusters", () => { } } } - + jest.spyOn(Cluster.prototype, "isClusterAdmin").mockReturnValue(Promise.resolve(true)) jest.spyOn(Cluster.prototype, "canI") .mockImplementationOnce((attr: V1ResourceAttributes): Promise => { expect(attr.namespace).toBe("default") @@ -159,7 +161,7 @@ describe("create clusters", () => { expect(c.accessible).toBe(true) expect(c.allowedNamespaces.length).toBe(1) expect(c.allowedResources.length).toBe(apiResources.length) - + c.disconnect() jest.resetAllMocks() }) }) diff --git a/src/main/cluster-detectors/base-cluster-detector.ts b/src/main/cluster-detectors/base-cluster-detector.ts new file mode 100644 index 0000000000..8663313005 --- /dev/null +++ b/src/main/cluster-detectors/base-cluster-detector.ts @@ -0,0 +1,33 @@ +import request, { RequestPromiseOptions } from "request-promise-native" +import { Cluster } from "../cluster"; + +export type ClusterDetectionResult = { + value: string | number | boolean + accuracy: number +} + +export class BaseClusterDetector { + cluster: Cluster + key: string + + constructor(cluster: Cluster) { + this.cluster = cluster + } + + detect(): Promise { + return null + } + + protected async k8sRequest(path: string, options: RequestPromiseOptions = {}): Promise { + const apiUrl = this.cluster.kubeProxyUrl + path; + return request(apiUrl, { + json: true, + timeout: 30000, + ...options, + headers: { + Host: `${this.cluster.id}.${new URL(this.cluster.kubeProxyUrl).host}`, // required in ClusterManager.getClusterForRequest() + ...(options.headers || {}), + }, + }) + } +} \ No newline at end of file diff --git a/src/main/cluster-detectors/cluster-id-detector.ts b/src/main/cluster-detectors/cluster-id-detector.ts new file mode 100644 index 0000000000..558e52d43c --- /dev/null +++ b/src/main/cluster-detectors/cluster-id-detector.ts @@ -0,0 +1,23 @@ +import { BaseClusterDetector } from "./base-cluster-detector"; +import { createHash } from "crypto" +import { ClusterMetadataKey } from "../cluster"; + +export class ClusterIdDetector extends BaseClusterDetector { + key = ClusterMetadataKey.CLUSTER_ID + + public async detect() { + let id: string + try { + id = await this.getDefaultNamespaceId() + } catch(_) { + id = this.cluster.apiUrl + } + const value = createHash("sha256").update(id).digest("hex") + return { value: value, accuracy: 100 } + } + + protected async getDefaultNamespaceId() { + const response = await this.k8sRequest("/api/v1/namespaces/default") + return response.metadata.uid + } +} \ No newline at end of file diff --git a/src/main/cluster-detectors/detector-registry.ts b/src/main/cluster-detectors/detector-registry.ts new file mode 100644 index 0000000000..577fdf2d85 --- /dev/null +++ b/src/main/cluster-detectors/detector-registry.ts @@ -0,0 +1,45 @@ +import { observable } from "mobx"; +import { ClusterMetadata } from "../../common/cluster-store"; +import { Cluster } from "../cluster"; +import { BaseClusterDetector, ClusterDetectionResult } from "./base-cluster-detector"; +import { ClusterIdDetector } from "./cluster-id-detector"; +import { DistributionDetector } from "./distribution-detector"; +import { LastSeenDetector } from "./last-seen-detector"; +import { NodesCountDetector } from "./nodes-count-detector"; +import { VersionDetector } from "./version-detector"; + +export class DetectorRegistry { + registry = observable.array([], { deep: false }); + + add(detectorClass: typeof BaseClusterDetector) { + this.registry.push(detectorClass) + } + + async detectForCluster(cluster: Cluster): Promise { + const results: {[key: string]: ClusterDetectionResult } = {} + for (const detectorClass of this.registry) { + const detector = new detectorClass(cluster) + try { + const data = await detector.detect() + if (!data) continue; + const existingValue = results[detector.key] + if (existingValue && existingValue.accuracy > data.accuracy) continue; // previous value exists and is more accurate + results[detector.key] = data + } catch (e) { + // detector raised error, do nothing + } + } + const metadata: ClusterMetadata = {} + for (const [key, result] of Object.entries(results)) { + metadata[key] = result.value + } + return metadata + } +} + +export const detectorRegistry = new DetectorRegistry() +detectorRegistry.add(ClusterIdDetector) +detectorRegistry.add(LastSeenDetector) +detectorRegistry.add(VersionDetector) +detectorRegistry.add(DistributionDetector) +detectorRegistry.add(NodesCountDetector) \ No newline at end of file diff --git a/src/main/cluster-detectors/distribution-detector.ts b/src/main/cluster-detectors/distribution-detector.ts new file mode 100644 index 0000000000..139150112f --- /dev/null +++ b/src/main/cluster-detectors/distribution-detector.ts @@ -0,0 +1,80 @@ +import { BaseClusterDetector } from "./base-cluster-detector"; +import { ClusterMetadataKey } from "../cluster"; + +export class DistributionDetector extends BaseClusterDetector { + key = ClusterMetadataKey.DISTRIBUTION + version: string + + public async detect() { + this.version = await this.getKubernetesVersion() + if (await this.isRancher()) { + return { value: "rancher", accuracy: 80} + } + if (this.isGKE()) { + return { value: "gke", accuracy: 80} + } + if (this.isEKS()) { + return { value: "eks", accuracy: 80} + } + if (this.isIKS()) { + return { value: "iks", accuracy: 80} + } + if (this.isAKS()) { + return { value: "aks", accuracy: 80} + } + if (this.isDigitalOcean()) { + return { value: "digitalocean", accuracy: 90} + } + if (this.isMinikube()) { + return { value: "minikube", accuracy: 80} + } + if (this.isCustom()) { + return { value: "custom", accuracy: 10} + } + return { value: "vanilla", accuracy: 10} + } + + public async getKubernetesVersion() { + if (this.cluster.version) return this.cluster.version + + const response = await this.k8sRequest("/version") + return response.gitVersion + } + + protected isGKE() { + return this.version.includes("gke") + } + + protected isEKS() { + return this.version.includes("eks") + } + + protected isIKS() { + return this.version.includes("IKS") + } + + protected isAKS() { + return this.cluster.apiUrl.endsWith("azmk8s.io") + } + + protected isDigitalOcean() { + return this.cluster.apiUrl.endsWith("k8s.ondigitalocean.com") + } + + protected isMinikube() { + return this.cluster.contextName.startsWith("minikube") + } + + protected isCustom() { + return this.version.includes("+") + } + + protected async isRancher() { + try { + const response = await this.k8sRequest("") + return response.data.find((api: any) => api?.apiVersion?.group === "meta.cattle.io") !== undefined + } catch (e) { + return false + } + } +} \ No newline at end of file diff --git a/src/main/cluster-detectors/last-seen-detector.ts b/src/main/cluster-detectors/last-seen-detector.ts new file mode 100644 index 0000000000..0c231116fe --- /dev/null +++ b/src/main/cluster-detectors/last-seen-detector.ts @@ -0,0 +1,13 @@ +import { BaseClusterDetector } from "./base-cluster-detector"; +import { ClusterMetadataKey } from "../cluster"; + +export class LastSeenDetector extends BaseClusterDetector { + key = ClusterMetadataKey.LAST_SEEN + + public async detect() { + if (!this.cluster.accessible) return null; + + await this.k8sRequest("/version") + return { value: new Date().toJSON(), accuracy: 100 } + } +} \ No newline at end of file diff --git a/src/main/cluster-detectors/nodes-count-detector.ts b/src/main/cluster-detectors/nodes-count-detector.ts new file mode 100644 index 0000000000..42ddf28742 --- /dev/null +++ b/src/main/cluster-detectors/nodes-count-detector.ts @@ -0,0 +1,18 @@ +import { BaseClusterDetector } from "./base-cluster-detector"; +import { ClusterMetadataKey } from "../cluster"; + +export class NodesCountDetector extends BaseClusterDetector { + key = ClusterMetadataKey.NODES_COUNT + + public async detect() { + const nodeCount = await this.getNodeCount() + return { value: nodeCount, accuracy: 100} + } + + protected async getNodeCount(): Promise { + if (!this.cluster.accessible) return null; + + const response = await this.k8sRequest("/api/v1/nodes") + return response.items.length + } +} \ No newline at end of file diff --git a/src/main/cluster-detectors/version-detector.ts b/src/main/cluster-detectors/version-detector.ts new file mode 100644 index 0000000000..4092b40b42 --- /dev/null +++ b/src/main/cluster-detectors/version-detector.ts @@ -0,0 +1,17 @@ +import { BaseClusterDetector } from "./base-cluster-detector"; +import { ClusterMetadataKey } from "../cluster"; + +export class VersionDetector extends BaseClusterDetector { + key = ClusterMetadataKey.VERSION + value: string + + public async detect() { + const version = await this.getKubernetesVersion() + return { value: version, accuracy: 100} + } + + public async getKubernetesVersion() { + const response = await this.k8sRequest("/version") + return response.gitVersion + } +} \ No newline at end of file diff --git a/src/main/cluster.ts b/src/main/cluster.ts index 3b9e89ef86..333a919c19 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -1,4 +1,4 @@ -import type { ClusterId, ClusterModel, ClusterPreferences } from "../common/cluster-store" +import type { ClusterId, ClusterMetadata, ClusterModel, ClusterPreferences } from "../common/cluster-store" import type { IMetricsReqParams } from "../renderer/api/endpoints/metrics.api"; import type { WorkspaceId } from "../common/workspace-store"; import type { FeatureStatusMap } from "./feature" @@ -14,6 +14,8 @@ import { getFeatures, installFeature, uninstallFeature, upgradeFeature } from ". import request, { RequestPromiseOptions } from "request-promise-native" import { apiResources } from "../common/rbac"; import logger from "./logger" +import { VersionDetector } from "./cluster-detectors/version-detector"; +import { detectorRegistry } from "./cluster-detectors/detector-registry"; export enum ClusterStatus { AccessGranted = 2, @@ -21,6 +23,18 @@ export enum ClusterStatus { Offline = 0 } +export enum ClusterMetadataKey { + VERSION = "version", + CLUSTER_ID = "id", + DISTRIBUTION = "distribution", + NODES_COUNT = "nodes", + LAST_SEEN = "lastSeen" +} + +export type ClusterRefreshOptions = { + refreshMetadata?: boolean +} + export interface ClusterState extends ClusterModel { initialized: boolean; apiUrl: string; @@ -29,10 +43,7 @@ export interface ClusterState extends ClusterModel { accessible: boolean; ready: boolean; failureReason: string; - nodes: number; eventCount: number; - version: string; - distribution: string; isAdmin: boolean; allowedNamespaces: string[] allowedResources: string[] @@ -63,12 +74,10 @@ export class Cluster implements ClusterModel { @observable reconnecting = false; @observable disconnected = true; @observable failureReason: string; - @observable nodes = 0; - @observable version: string; - @observable distribution = "unknown"; @observable isAdmin = false; @observable eventCount = 0; @observable preferences: ClusterPreferences = {}; + @observable metadata: ClusterMetadata = {}; @observable features: FeatureStatusMap = {}; @observable allowedNamespaces: string[] = []; @observable allowedResources: string[] = []; @@ -76,6 +85,9 @@ export class Cluster implements ClusterModel { @computed get available() { return this.accessible && !this.disconnected; } + get version(): string { + return String(this.metadata?.version) || "" + } constructor(model: ClusterModel) { this.updateModel(model); @@ -113,10 +125,14 @@ export class Cluster implements ClusterModel { protected bindEvents() { logger.info(`[CLUSTER]: bind events`, this.getMeta()); const refreshTimer = setInterval(() => !this.disconnected && this.refresh(), 30000); // every 30s + const refreshMetadataTimer = setInterval(() => !this.disconnected && this.refreshMetadata(), 900000); // every 15 minutes this.eventDisposers.push( reaction(this.getState, this.pushState), - () => clearInterval(refreshTimer), + () => { + clearInterval(refreshTimer); + clearInterval(refreshMetadataTimer); + }, ); } @@ -142,6 +158,7 @@ export class Cluster implements ClusterModel { await this.refreshConnectionStatus() if (this.accessible) { await this.refreshAllowedResources() + this.isAdmin = await this.isClusterAdmin() this.ready = true this.kubeCtl = new Kubectl(this.version) this.kubeCtl.ensureKubectl() // download kubectl in background, so it's not blocking dashboard @@ -172,29 +189,37 @@ export class Cluster implements ClusterModel { } @action - async refresh() { + async refresh(opts: ClusterRefreshOptions = {}) { logger.info(`[CLUSTER]: refresh`, this.getMeta()); await this.whenInitialized; await this.refreshConnectionStatus(); if (this.accessible) { - this.distribution = this.detectKubernetesDistribution(this.version) - const [features, isAdmin, nodesCount] = await Promise.all([ + const [features, isAdmin] = await Promise.all([ getFeatures(this), this.isClusterAdmin(), - this.getNodeCount(), ]); this.features = features; this.isAdmin = isAdmin; - this.nodes = nodesCount; await Promise.all([ this.refreshEvents(), this.refreshAllowedResources(), ]); + if (opts.refreshMetadata) { + this.refreshMetadata() + } this.ready = true } this.pushState(); } + @action + async refreshMetadata() { + logger.info(`[CLUSTER]: refreshMetadata`, this.getMeta()); + const metadata = await detectorRegistry.detectForCluster(this) + const existingMetadata = this.metadata + this.metadata = Object.assign(existingMetadata, metadata) + } + @action async refreshConnectionStatus() { const connectionStatus = await this.getConnectionStatus(); @@ -263,9 +288,9 @@ export class Cluster implements ClusterModel { protected async getConnectionStatus(): Promise { try { - const response = await this.k8sRequest("/version") - this.version = response.gitVersion - this.failureReason = null + const versionDetector = new VersionDetector(this) + const versionData = await versionDetector.detect() + this.metadata.version = versionData.value return ClusterStatus.AccessGranted; } catch (error) { logger.error(`Failed to connect cluster "${this.contextName}": ${error}`) @@ -314,27 +339,6 @@ export class Cluster implements ClusterModel { }) } - protected detectKubernetesDistribution(kubernetesVersion: string): string { - if (kubernetesVersion.includes("gke")) return "gke" - if (kubernetesVersion.includes("eks")) return "eks" - if (kubernetesVersion.includes("IKS")) return "iks" - if (this.apiUrl.endsWith("azmk8s.io")) return "aks" - if (this.apiUrl.endsWith("k8s.ondigitalocean.com")) return "digitalocean" - if (this.contextName.startsWith("minikube")) return "minikube" - if (kubernetesVersion.includes("+")) return "custom" - return "vanilla" - } - - protected async getNodeCount(): Promise { - try { - const response = await this.k8sRequest("/api/v1/nodes") - return response.items.length - } catch (error) { - logger.debug(`failed to request node list: ${error.message}`) - return null - } - } - protected async getEventCount(): Promise { if (!this.isAdmin) { return 0; @@ -377,6 +381,7 @@ export class Cluster implements ClusterModel { kubeConfigPath: this.kubeConfigPath, workspace: this.workspace, preferences: this.preferences, + metadata: this.metadata, }; return toJS(model, { recurseEverything: true @@ -394,9 +399,6 @@ export class Cluster implements ClusterModel { disconnected: this.disconnected, accessible: this.accessible, failureReason: this.failureReason, - nodes: this.nodes, - version: this.version, - distribution: this.distribution, isAdmin: this.isAdmin, features: this.features, eventCount: this.eventCount, diff --git a/src/renderer/components/+cluster-settings/cluster-settings.tsx b/src/renderer/components/+cluster-settings/cluster-settings.tsx index 9558b26967..ef886dde37 100644 --- a/src/renderer/components/+cluster-settings/cluster-settings.tsx +++ b/src/renderer/components/+cluster-settings/cluster-settings.tsx @@ -49,7 +49,7 @@ export class ClusterSettings extends React.Component { refreshCluster = async () => { if(this.cluster) { await clusterIpc.activate.invokeFromRenderer(this.cluster.id); - clusterIpc.refresh.invokeFromRenderer(this.cluster.id); + await clusterIpc.refresh.invokeFromRenderer(this.cluster.id); } } diff --git a/src/renderer/components/+cluster-settings/status.tsx b/src/renderer/components/+cluster-settings/status.tsx index d79d4e4969..79fb62c860 100644 --- a/src/renderer/components/+cluster-settings/status.tsx +++ b/src/renderer/components/+cluster-settings/status.tsx @@ -21,10 +21,10 @@ export class Status extends React.Component { const { cluster } = this.props; const rows = [ ["Online Status", cluster.online ? "online" : `offline (${cluster.failureReason || "unknown reason"})`], - ["Distribution", cluster.distribution], - ["Kernel Version", cluster.version], - ["API Address", cluster.apiUrl], - ["Nodes Count", cluster.nodes || "0"] + ["Distribution", cluster.metadata.distribution ? String(cluster.metadata.distribution) : "N/A"], + ["Kernel Version", cluster.metadata.version ? String(cluster.metadata.version) : "N/A"], + ["API Address", cluster.apiUrl || "N/A"], + ["Nodes Count", cluster.metadata.nodes ? String(cluster.metadata.nodes) : "N/A"] ]; return (