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

Implement cluster metadata detectors (#1106)

Signed-off-by: Lauri Nevala <lauri.nevala@gmail.com>
This commit is contained in:
Lauri Nevala 2020-10-22 21:45:54 +03:00 committed by GitHub
parent 99db7aca19
commit ce995f3deb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 293 additions and 49 deletions

View File

@ -90,13 +90,19 @@ export class BaseStore<T = any> 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)
}

View File

@ -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 })
},
}),

View File

@ -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 */

View File

@ -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<typeof request>
@ -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<boolean> => {
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()
})
})

View File

@ -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<ClusterDetectionResult> {
return null
}
protected async k8sRequest<T = any>(path: string, options: RequestPromiseOptions = {}): Promise<T> {
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 || {}),
},
})
}
}

View File

@ -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
}
}

View File

@ -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<typeof BaseClusterDetector>([], { deep: false });
add(detectorClass: typeof BaseClusterDetector) {
this.registry.push(detectorClass)
}
async detectForCluster(cluster: Cluster): Promise<ClusterMetadata> {
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)

View File

@ -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
}
}
}

View File

@ -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 }
}
}

View File

@ -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<number> {
if (!this.cluster.accessible) return null;
const response = await this.k8sRequest("/api/v1/nodes")
return response.items.length
}
}

View File

@ -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
}
}

View File

@ -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<ClusterStatus> {
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<number> {
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<number> {
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,

View File

@ -49,7 +49,7 @@ export class ClusterSettings extends React.Component<Props> {
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);
}
}

View File

@ -21,10 +21,10 @@ export class Status extends React.Component<Props> {
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 (
<Table scrollable={false}>