Cleanup 'Cluster' to remove environment specific details (#6951)

- requestNamespaceListPermissions is infallable so no need to have the extra try/catch

- Refactor isMetricHidden method away from Cluster

- Refactor shouldShowResource out of Cluster

- Refactor isInLocalKubeconfig out of Cluster

- Remove depecrated and unused workspace from Cluster

- Refactor out kubectl as a dependency of Cluster

- Remove from cluster getter used only once

- Split out ClusterConnection from Cluster

- Also split out KubeAuthProxyServer from ContextHandler

- Rename ContextHandler to PrometheusHandler

- Cleanup onNetworkOffline/Online impls within ClusterManager

- Remove annotations from ClusterConnection

- Remove mobx annotations from Cluster

- Rename loadConfigFromFileInjectable

- Remove all uses of dead createClusterInjectionToken

- Fix type errors related to broadcastConnectionUpdate

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton authored and GitHub committed 2023-03-10 09:37:39 +02:00
1 parent e6d6d1d8f7
commit ad9bafe2a5
133 files changed
+2544 -1961

No files matched your search

@@ -0,0 +1,21 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import type { Cluster } from "../../common/cluster/cluster";
import lensProxyPortInjectable from "../lens-proxy/lens-proxy-port.injectable";
const kubeAuthProxyUrlInjectable = getInjectable({
id: "kube-auth-proxy-url",
instantiate: (di, cluster) => {
const lensProxyPort = di.inject(lensProxyPortInjectable);
return `https://127.0.0.1:${lensProxyPort.get()}/${cluster.id}`;
},
lifecycle: lifecycleEnum.keyedSingleton({
getInstanceKey: (di, cluster: Cluster) => cluster.id,
}),
});
export default kubeAuthProxyUrlInjectable;
@@ -0,0 +1,29 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import type { KubeAuthUpdate } from "../../common/cluster-types";
import type { Cluster } from "../../common/cluster/cluster";
import broadcastMessageInjectable from "../../common/ipc/broadcast-message.injectable";
import loggerInjectable from "../../common/logger.injectable";
export type BroadcastConnectionUpdate = (update: KubeAuthUpdate) => void;
const broadcastConnectionUpdateInjectable = getInjectable({
id: "broadcast-connection-update",
instantiate: (di, cluster): BroadcastConnectionUpdate => {
const broadcastMessage = di.inject(broadcastMessageInjectable);
const logger = di.inject(loggerInjectable);
return (update) => {
logger.debug(`[CLUSTER]: broadcasting connection update`, { ...update, meta: cluster.getMeta() });
broadcastMessage(`cluster:${cluster.id}:connection-update`, update);
};
},
lifecycle: lifecycleEnum.keyedSingleton({
getInstanceKey: (di, cluster: Cluster) => cluster.id,
}),
});
export default broadcastConnectionUpdateInjectable;
@@ -0,0 +1,423 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { type KubeConfig, HttpError } from "@kubernetes/client-node";
import { reaction, comparer, runInAction } from "mobx";
import { ClusterStatus } from "../../common/cluster-types";
import type { CreateAuthorizationReview } from "../../common/cluster/authorization-review.injectable";
import type { Cluster } from "../../common/cluster/cluster";
import type { CreateListNamespaces } from "../../common/cluster/list-namespaces.injectable";
import type { RequestNamespaceListPermissionsFor, RequestNamespaceListPermissions } from "../../common/cluster/request-namespace-list-permissions.injectable";
import type { BroadcastMessage } from "../../common/ipc/broadcast-message.injectable";
import { clusterListNamespaceForbiddenChannel } from "../../common/ipc/cluster";
import type { Logger } from "../../common/logger";
import type { KubeApiResource } from "../../common/rbac";
import { formatKubeApiResource } from "../../common/rbac";
import { disposer, isDefined, isRequestError } from "../../common/utils";
import { withConcurrencyLimit } from "../../common/utils/with-concurrency-limit";
import type { ClusterPrometheusHandler } from "./prometheus-handler/prometheus-handler";
import type { BroadcastConnectionUpdate } from "./broadcast-connection-update.injectable";
import type { KubeAuthProxyServer } from "./kube-auth-proxy-server.injectable";
import type { LoadProxyKubeconfig } from "./load-proxy-kubeconfig.injectable";
import type { RemoveProxyKubeconfig } from "./remove-proxy-kubeconfig.injectable";
import type { RequestApiResources } from "./request-api-resources.injectable";
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import broadcastConnectionUpdateInjectable from "./broadcast-connection-update.injectable";
import broadcastMessageInjectable from "../../common/ipc/broadcast-message.injectable";
import createAuthorizationReviewInjectable from "../../common/cluster/authorization-review.injectable";
import createListNamespacesInjectable from "../../common/cluster/list-namespaces.injectable";
import kubeAuthProxyServerInjectable from "./kube-auth-proxy-server.injectable";
import loadProxyKubeconfigInjectable from "./load-proxy-kubeconfig.injectable";
import loggerInjectable from "../../common/logger.injectable";
import prometheusHandlerInjectable from "./prometheus-handler/prometheus-handler.injectable";
import removeProxyKubeconfigInjectable from "./remove-proxy-kubeconfig.injectable";
import requestApiResourcesInjectable from "./request-api-resources.injectable";
import requestNamespaceListPermissionsForInjectable from "../../common/cluster/request-namespace-list-permissions.injectable";
import type { DetectClusterMetadata } from "../cluster-detectors/detect-cluster-metadata.injectable";
import type { FallibleOnlyClusterMetadataDetector } from "../cluster-detectors/token";
import clusterVersionDetectorInjectable from "../cluster-detectors/cluster-version-detector.injectable";
import detectClusterMetadataInjectable from "../cluster-detectors/detect-cluster-metadata.injectable";
import { replaceObservableObject } from "../../common/utils/replace-observable-object";
interface Dependencies {
readonly logger: Logger;
readonly prometheusHandler: ClusterPrometheusHandler;
readonly kubeAuthProxyServer: KubeAuthProxyServer;
readonly clusterVersionDetector: FallibleOnlyClusterMetadataDetector;
createAuthorizationReview: CreateAuthorizationReview;
requestApiResources: RequestApiResources;
requestNamespaceListPermissionsFor: RequestNamespaceListPermissionsFor;
createListNamespaces: CreateListNamespaces;
detectClusterMetadata: DetectClusterMetadata;
broadcastMessage: BroadcastMessage;
broadcastConnectionUpdate: BroadcastConnectionUpdate;
loadProxyKubeconfig: LoadProxyKubeconfig;
removeProxyKubeconfig: RemoveProxyKubeconfig;
}
export type { ClusterConnection };
class ClusterConnection {
protected readonly eventsDisposer = disposer();
protected activated = false;
constructor(
private readonly dependencies: Dependencies,
private readonly cluster: Cluster,
) {}
private bindEvents() {
this.dependencies.logger.info(`[CLUSTER]: bind events`, this.cluster.getMeta());
const refreshTimer = setInterval(() => {
if (!this.cluster.disconnected.get()) {
this.refresh();
}
}, 30_000); // every 30s
const refreshMetadataTimer = setInterval(() => {
if (this.cluster.available.get()) {
this.refreshAccessibilityAndMetadata();
}
}, 900000); // every 15 minutes
this.eventsDisposer.push(
reaction(
() => this.cluster.prometheusPreferences.get(),
preferences => this.dependencies.prometheusHandler.setupPrometheus(preferences),
{ equals: comparer.structural },
),
() => clearInterval(refreshTimer),
() => clearInterval(refreshMetadataTimer),
reaction(() => this.cluster.preferences.defaultNamespace, () => this.recreateProxyKubeconfig()),
);
}
protected async recreateProxyKubeconfig() {
this.dependencies.logger.info("[CLUSTER]: Recreating proxy kubeconfig");
try {
await this.dependencies.removeProxyKubeconfig();
await this.dependencies.loadProxyKubeconfig();
} catch (error) {
this.dependencies.logger.error(`[CLUSTER]: failed to recreate proxy kubeconfig`, error);
}
}
/**
* @param force force activation
*/
async activate(force = false) {
if (this.activated && !force) {
return;
}
this.dependencies.logger.info(`[CLUSTER]: activate`, this.cluster.getMeta());
if (!this.eventsDisposer.length) {
this.bindEvents();
}
if (this.cluster.disconnected || !this.cluster.accessible.get()) {
try {
this.dependencies.broadcastConnectionUpdate({
level: "info",
message: "Starting connection ...",
});
await this.reconnect();
} catch (error) {
this.dependencies.broadcastConnectionUpdate({
level: "error",
message: `Failed to start connection: ${error}`,
});
return;
}
}
try {
this.dependencies.broadcastConnectionUpdate({
level: "info",
message: "Refreshing connection status ...",
});
await this.refreshConnectionStatus();
} catch (error) {
this.dependencies.broadcastConnectionUpdate({
level: "error",
message: `Failed to connection status: ${error}`,
});
return;
}
if (this.cluster.accessible.get()) {
try {
this.dependencies.broadcastConnectionUpdate({
level: "info",
message: "Refreshing cluster accessibility ...",
});
await this.refreshAccessibility();
} catch (error) {
this.dependencies.broadcastConnectionUpdate({
level: "error",
message: `Failed to refresh accessibility: ${error}`,
});
return;
}
this.dependencies.broadcastConnectionUpdate({
level: "info",
message: "Connected, waiting for view to load ...",
});
}
this.activated = true;
}
async reconnect() {
this.dependencies.logger.info(`[CLUSTER]: reconnect`, this.cluster.getMeta());
await this.dependencies.kubeAuthProxyServer?.restart();
runInAction(() => {
this.cluster.disconnected.set(false);
});
}
disconnect() {
if (this.cluster.disconnected) {
return this.dependencies.logger.debug("[CLUSTER]: already disconnected", { id: this.cluster.id });
}
runInAction(() => {
this.dependencies.logger.info(`[CLUSTER]: disconnecting`, { id: this.cluster.id });
this.eventsDisposer();
this.dependencies.kubeAuthProxyServer?.stop();
this.cluster.disconnected.set(true);
this.cluster.online.set(false);
this.cluster.accessible.set(false);
this.cluster.ready.set(false);
this.activated = false;
this.cluster.allowedNamespaces.clear();
this.dependencies.logger.info(`[CLUSTER]: disconnected`, { id: this.cluster.id });
});
}
async refresh() {
this.dependencies.logger.info(`[CLUSTER]: refresh`, this.cluster.getMeta());
await this.refreshConnectionStatus();
}
async refreshAccessibilityAndMetadata() {
await this.refreshAccessibility();
await this.refreshMetadata();
}
async refreshMetadata() {
this.dependencies.logger.info(`[CLUSTER]: refreshMetadata`, this.cluster.getMeta());
const metadata = await this.dependencies.detectClusterMetadata(this.cluster);
runInAction(() => {
replaceObservableObject(this.cluster.metadata, metadata);
});
}
private async refreshAccessibility(): Promise<void> {
this.dependencies.logger.info(`[CLUSTER]: refreshAccessibility`, this.cluster.getMeta());
const proxyConfig = await this.dependencies.loadProxyKubeconfig();
const canI = this.dependencies.createAuthorizationReview(proxyConfig);
const requestNamespaceListPermissions = this.dependencies.requestNamespaceListPermissionsFor(proxyConfig);
const isAdmin = await canI({
namespace: "kube-system",
resource: "*",
verb: "create",
});
const isGlobalWatchEnabled = await canI({
verb: "watch",
resource: "*",
});
const allowedNamespaces = await this.requestAllowedNamespaces(proxyConfig);
const knownResources = await (async () => {
const result = await this.dependencies.requestApiResources(this.cluster);
if (result.callWasSuccessful) {
return result.response;
}
if (this.cluster.knownResources.length > 0) {
this.dependencies.logger.warn(`[CLUSTER]: failed to list KUBE resources, sticking with previous list`);
return this.cluster.knownResources;
}
this.dependencies.logger.warn(`[CLUSTER]: failed to list KUBE resources for the first time, blocking connection to cluster...`);
this.dependencies.broadcastConnectionUpdate({
level: "error",
message: "Failed to list kube API resources, please reconnect...",
});
return [];
})();
const resourcesToShow = await this.getResourcesToShow(allowedNamespaces, knownResources, requestNamespaceListPermissions);
runInAction(() => {
this.cluster.isAdmin.set(isAdmin);
this.cluster.isGlobalWatchEnabled.set(isGlobalWatchEnabled);
this.cluster.allowedNamespaces.replace(allowedNamespaces);
this.cluster.knownResources.replace(knownResources);
this.cluster.resourcesToShow.replace(resourcesToShow);
this.cluster.ready.set(this.cluster.knownResources.length > 0);
});
this.dependencies.logger.debug(`[CLUSTER]: refreshed accessibility data`, this.cluster.getState());
}
async refreshConnectionStatus() {
const connectionStatus = await this.getConnectionStatus();
runInAction(() => {
this.cluster.online.set(connectionStatus > ClusterStatus.Offline);
this.cluster.accessible.set(connectionStatus == ClusterStatus.AccessGranted);
});
}
protected async getConnectionStatus(): Promise<ClusterStatus> {
try {
const versionData = await this.dependencies.clusterVersionDetector.detect(this.cluster);
runInAction(() => {
this.cluster.metadata.version = versionData.value;
});
return ClusterStatus.AccessGranted;
} catch (error) {
this.dependencies.logger.error(`[CLUSTER]: Failed to connect to "${this.cluster.contextName.get()}": ${error}`);
if (isRequestError(error)) {
if (error.statusCode) {
if (error.statusCode >= 400 && error.statusCode < 500) {
this.dependencies.broadcastConnectionUpdate({
level: "error",
message: "Invalid credentials",
});
return ClusterStatus.AccessDenied;
}
const message = String(error.error || error.message) || String(error);
this.dependencies.broadcastConnectionUpdate({
level: "error",
message,
});
return ClusterStatus.Offline;
}
if (error.failed === true) {
if (error.timedOut === true) {
this.dependencies.broadcastConnectionUpdate({
level: "error",
message: "Connection timed out",
});
return ClusterStatus.Offline;
}
this.dependencies.broadcastConnectionUpdate({
level: "error",
message: "Failed to fetch credentials",
});
return ClusterStatus.AccessDenied;
}
const message = String(error.error || error.message) || String(error);
this.dependencies.broadcastConnectionUpdate({
level: "error",
message,
});
} else if (error instanceof Error || typeof error === "string") {
this.dependencies.broadcastConnectionUpdate({
level: "error",
message: `${error}`,
});
} else {
this.dependencies.broadcastConnectionUpdate({
level: "error",
message: "Unknown error has occurred",
});
}
return ClusterStatus.Offline;
}
}
protected async requestAllowedNamespaces(proxyConfig: KubeConfig) {
if (this.cluster.accessibleNamespaces.length) {
return this.cluster.accessibleNamespaces;
}
try {
const listNamespaces = this.dependencies.createListNamespaces(proxyConfig);
return await listNamespaces();
} catch (error) {
const ctx = proxyConfig.getContextObject(this.cluster.contextName.get());
const namespaceList = [ctx?.namespace].filter(isDefined);
if (namespaceList.length === 0 && error instanceof HttpError && error.statusCode === 403) {
const { response } = error as HttpError & { response: Response };
this.dependencies.logger.info("[CLUSTER]: listing namespaces is forbidden, broadcasting", { clusterId: this.cluster.id, error: response.body });
this.dependencies.broadcastMessage(clusterListNamespaceForbiddenChannel, this.cluster.id);
}
return namespaceList;
}
}
protected async getResourcesToShow(allowedNamespaces: string[], knownResources: KubeApiResource[], req: RequestNamespaceListPermissions) {
if (!allowedNamespaces.length) {
return [];
}
const requestNamespaceListPermissions = withConcurrencyLimit(5)(req);
const namespaceListPermissions = allowedNamespaces.map(requestNamespaceListPermissions);
const canListResources = await Promise.all(namespaceListPermissions);
return knownResources
.filter((resource) => canListResources.some(fn => fn(resource)))
.map(formatKubeApiResource);
}
}
const clusterConnectionInjectable = getInjectable({
id: "cluster-connection",
instantiate: (di, cluster) => new ClusterConnection(
{
clusterVersionDetector: di.inject(clusterVersionDetectorInjectable),
kubeAuthProxyServer: di.inject(kubeAuthProxyServerInjectable, cluster),
logger: di.inject(loggerInjectable),
prometheusHandler: di.inject(prometheusHandlerInjectable, cluster),
broadcastConnectionUpdate: di.inject(broadcastConnectionUpdateInjectable, cluster),
broadcastMessage: di.inject(broadcastMessageInjectable),
createAuthorizationReview: di.inject(createAuthorizationReviewInjectable),
createListNamespaces: di.inject(createListNamespacesInjectable),
detectClusterMetadata: di.inject(detectClusterMetadataInjectable),
loadProxyKubeconfig: di.inject(loadProxyKubeconfigInjectable, cluster),
removeProxyKubeconfig: di.inject(removeProxyKubeconfigInjectable, cluster),
requestApiResources: di.inject(requestApiResourcesInjectable),
requestNamespaceListPermissionsFor: di.inject(requestNamespaceListPermissionsForInjectable),
},
cluster,
),
lifecycle: lifecycleEnum.keyedSingleton({
getInstanceKey: (di, cluster: Cluster) => cluster.id,
}),
});
export default clusterConnectionInjectable;
@@ -0,0 +1,114 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { ServerOptions } from "http-proxy";
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import type { Cluster } from "../../common/cluster/cluster";
import createKubeAuthProxyInjectable from "../kube-auth-proxy/create-kube-auth-proxy.injectable";
import kubeAuthProxyCertificateInjectable from "../kube-auth-proxy/kube-auth-proxy-certificate.injectable";
import type { KubeAuthProxy } from "../kube-auth-proxy/kube-auth-proxy";
export interface KubeAuthProxyServer {
getApiTarget(isLongRunningRequest?: boolean): Promise<ServerOptions>;
ensureAuthProxyUrl(): Promise<string>;
restart(): Promise<void>;
ensureRunning(): Promise<void>;
stop(): void;
}
const fourHoursInMs = 4 * 60 * 60 * 1000;
const thirtySecondsInMs = 30 * 1000;
const kubeAuthProxyServerInjectable = getInjectable({
id: "kube-auth-proxy-server",
instantiate: (di, cluster): KubeAuthProxyServer => {
const clusterUrl = new URL(cluster.apiUrl.get());
const createKubeAuthProxy = di.inject(createKubeAuthProxyInjectable);
const certificate = di.inject(kubeAuthProxyCertificateInjectable, clusterUrl.hostname);
let kubeAuthProxy: KubeAuthProxy | undefined = undefined;
let apiTarget: ServerOptions | undefined = undefined;
const ensureServerHelper = async (): Promise<KubeAuthProxy> => {
if (!kubeAuthProxy) {
const proxyEnv = Object.assign({}, process.env);
if (cluster.preferences.httpsProxy) {
proxyEnv.HTTPS_PROXY = cluster.preferences.httpsProxy;
}
kubeAuthProxy = createKubeAuthProxy(cluster, proxyEnv);
}
await kubeAuthProxy.run();
return kubeAuthProxy;
};
const newApiTarget = async (timeout: number): Promise<ServerOptions> => {
const kubeAuthProxy = await ensureServerHelper();
const headers: Record<string, string> = {};
if (clusterUrl.hostname) {
headers.Host = clusterUrl.hostname;
// fix current IPv6 inconsistency in url.Parse() and httpProxy.
// with url.Parse the IPv6 Hostname has no Square brackets but httpProxy needs the Square brackets to work.
if (headers.Host.includes(":")) {
headers.Host = `[${headers.Host}]`;
}
}
return {
target: {
protocol: "https:",
host: "127.0.0.1",
port: kubeAuthProxy.port,
path: kubeAuthProxy.apiPrefix,
ca: certificate.cert,
},
changeOrigin: true,
timeout,
secure: true,
headers,
};
};
const stopServer = () => {
kubeAuthProxy?.exit();
kubeAuthProxy = undefined;
apiTarget = undefined;
};
return {
getApiTarget: async (isLongRunningRequest = false) => {
if (isLongRunningRequest) {
return newApiTarget(fourHoursInMs);
}
return apiTarget ??= await newApiTarget(thirtySecondsInMs);
},
ensureAuthProxyUrl: async () => {
const kubeAuthProxy = await ensureServerHelper();
return `https://127.0.0.1:${kubeAuthProxy.port}${kubeAuthProxy.apiPrefix}`;
},
ensureRunning: async () => {
await ensureServerHelper();
},
restart: async () => {
stopServer();
await ensureServerHelper();
},
stop: stopServer,
};
},
lifecycle: lifecycleEnum.keyedSingleton({
getInstanceKey: (di, cluster: Cluster) => cluster.id,
}),
});
export default kubeAuthProxyServerInjectable;
@@ -0,0 +1,31 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { KubeConfig } from "@kubernetes/client-node";
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import type { Cluster } from "../../common/cluster/cluster";
import loadConfigFromFileInjectable from "../../common/kube-helpers/load-config-from-file.injectable";
import kubeconfigManagerInjectable from "../kubeconfig-manager/kubeconfig-manager.injectable";
export type LoadProxyKubeconfig = () => Promise<KubeConfig>;
const loadProxyKubeconfigInjectable = getInjectable({
id: "load-proxy-kubeconfig",
instantiate: (di, cluster) => {
const loadConfigFromFile = di.inject(loadConfigFromFileInjectable);
const proxyKubeconfigManager = di.inject(kubeconfigManagerInjectable, cluster);
return async () => {
const proxyKubeconfigPath = await proxyKubeconfigManager.ensurePath();
const { config } = await loadConfigFromFile(proxyKubeconfigPath);
return config;
};
},
lifecycle: lifecycleEnum.keyedSingleton({
getInstanceKey: (di, cluster: Cluster) => cluster.id,
}),
});
export default loadProxyKubeconfigInjectable;
@@ -7,6 +7,7 @@ import clusterStoreInjectable from "../../common/cluster-store/cluster-store.inj
import loggerInjectable from "../../common/logger.injectable";
import catalogEntityRegistryInjectable from "../catalog/entity-registry.injectable";
import clustersThatAreBeingDeletedInjectable from "./are-being-deleted.injectable";
import clusterConnectionInjectable from "./cluster-connection.injectable";
import { ClusterManager } from "./manager";
import updateEntityMetadataInjectable from "./update-entity-metadata.injectable";
import updateEntitySpecInjectable from "./update-entity-spec.injectable";
@@ -23,6 +24,7 @@ const clusterManagerInjectable = getInjectable({
logger: di.inject(loggerInjectable),
updateEntityMetadata: di.inject(updateEntityMetadataInjectable),
updateEntitySpec: di.inject(updateEntitySpecInjectable),
getClusterConnection: (cluster) => di.inject(clusterConnectionInjectable, cluster),
}),
});
+45 -30
View File
@@ -17,6 +17,7 @@ import type { CatalogEntityRegistry } from "../catalog";
import type { Logger } from "../../common/logger";
import type { UpdateEntityMetadata } from "./update-entity-metadata.injectable";
import type { UpdateEntitySpec } from "./update-entity-spec.injectable";
import type { ClusterConnection } from "./cluster-connection.injectable";
const logPrefix = "[CLUSTER-MANAGER]:";
@@ -28,8 +29,9 @@ interface Dependencies {
readonly clustersThatAreBeingDeleted: ObservableSet<ClusterId>;
readonly visibleCluster: IObservableValue<ClusterId | null>;
readonly logger: Logger;
readonly updateEntityMetadata: UpdateEntityMetadata;
readonly updateEntitySpec: UpdateEntitySpec;
updateEntityMetadata: UpdateEntityMetadata;
updateEntitySpec: UpdateEntitySpec;
getClusterConnection: (cluster: Cluster) => ClusterConnection;
}
export class ClusterManager {
@@ -119,13 +121,13 @@ export class ClusterManager {
return LensKubernetesClusterStatus.DISCONNECTED;
}
if (cluster.accessible) {
if (cluster.accessible.get()) {
this.dependencies.logger.silly(`${logPrefix} setting entity ${entity.getName()} to CONNECTED, reason="cluster is accessible"`);
return LensKubernetesClusterStatus.CONNECTED;
}
if (!cluster.disconnected) {
if (!cluster.disconnected.get()) {
this.dependencies.logger.silly(`${logPrefix} setting entity ${entity.getName()} to CONNECTING, reason="cluster is not disconnected"`);
return LensKubernetesClusterStatus.CONNECTING;
@@ -174,8 +176,8 @@ export class ClusterManager {
}
}
} else {
cluster.kubeConfigPath = entity.spec.kubeconfigPath;
cluster.contextName = entity.spec.kubeconfigContext;
cluster.kubeConfigPath.set(entity.spec.kubeconfigPath);
cluster.contextName.set(entity.spec.kubeconfigContext);
if (entity.spec.accessibleNamespaces) {
cluster.accessibleNamespaces.replace(entity.spec.accessibleNamespaces);
@@ -202,30 +204,43 @@ export class ClusterManager {
}
}
protected onNetworkOffline = () => {
protected onNetworkOffline = async () => {
this.dependencies.logger.info(`${logPrefix} network is offline`);
this.dependencies.store.clustersList.forEach((cluster) => {
if (!cluster.disconnected) {
cluster.online = false;
cluster.accessible = false;
cluster.refreshConnectionStatus().catch((e) => e);
}
});
await Promise.allSettled(
this.dependencies.store.clustersList
.filter(cluster => !cluster.disconnected.get())
.map(async (cluster) => {
cluster.online.set(false);
cluster.accessible.set(false);
await this.dependencies
.getClusterConnection(cluster)
.refreshConnectionStatus();
}),
);
};
protected onNetworkOnline = () => {
protected onNetworkOnline = async () => {
this.dependencies.logger.info(`${logPrefix} network is online`);
this.dependencies.store.clustersList.forEach((cluster) => {
if (!cluster.disconnected) {
cluster.refreshConnectionStatus().catch((e) => e);
}
});
await Promise.allSettled(
this.dependencies.store.clustersList
.filter(cluster => !cluster.disconnected.get())
.map((cluster) => (
this.dependencies
.getClusterConnection(cluster)
.refreshConnectionStatus()
)),
);
};
stop() {
this.dependencies.store.clusters.forEach((cluster: Cluster) => {
cluster.disconnect();
});
for (const cluster of this.dependencies.store.clustersList) {
this.dependencies
.getClusterConnection(cluster)
.disconnect();
}
}
}
@@ -233,26 +248,26 @@ export function catalogEntityFromCluster(cluster: Cluster) {
return new KubernetesCluster({
metadata: {
uid: cluster.id,
name: cluster.name,
name: cluster.name.get(),
source: "local",
labels: {
...cluster.labels,
},
distro: cluster.distribution,
kubeVersion: cluster.version,
distro: cluster.distribution.get(),
kubeVersion: cluster.version.get(),
},
spec: {
kubeconfigPath: cluster.kubeConfigPath,
kubeconfigContext: cluster.contextName,
kubeconfigPath: cluster.kubeConfigPath.get(),
kubeconfigContext: cluster.contextName.get(),
icon: {},
},
status: {
phase: cluster.disconnected
phase: cluster.disconnected.get()
? LensKubernetesClusterStatus.DISCONNECTED
: LensKubernetesClusterStatus.CONNECTED,
reason: "",
message: "",
active: !cluster.disconnected,
active: !cluster.disconnected.get(),
},
});
}
@@ -0,0 +1,30 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import type { Cluster } from "../../../common/cluster/cluster";
import { createClusterPrometheusHandler } from "./prometheus-handler";
import getPrometheusProviderByKindInjectable from "../../prometheus/get-by-kind.injectable";
import prometheusProvidersInjectable from "../../prometheus/providers.injectable";
import loggerInjectable from "../../../common/logger.injectable";
import loadProxyKubeconfigInjectable from "../load-proxy-kubeconfig.injectable";
const prometheusHandlerInjectable = getInjectable({
id: "prometheus-handler",
instantiate: (di, cluster) => createClusterPrometheusHandler(
{
getPrometheusProviderByKind: di.inject(getPrometheusProviderByKindInjectable),
prometheusProviders: di.inject(prometheusProvidersInjectable),
logger: di.inject(loggerInjectable),
loadProxyKubeconfig: di.inject(loadProxyKubeconfigInjectable, cluster),
},
cluster,
),
lifecycle: lifecycleEnum.keyedSingleton({
getInstanceKey: (di, cluster: Cluster) => cluster.id,
}),
});
export default prometheusHandlerInjectable;
@@ -0,0 +1,129 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { PrometheusProvider, PrometheusService } from "../../prometheus/provider";
import type { ClusterPrometheusPreferences } from "../../../common/cluster-types";
import type { Cluster } from "../../../common/cluster/cluster";
import { CoreV1Api } from "@kubernetes/client-node";
import type { GetPrometheusProviderByKind } from "../../prometheus/get-by-kind.injectable";
import type { IComputedValue } from "mobx";
import type { Logger } from "../../../common/logger";
import type { LoadProxyKubeconfig } from "../load-proxy-kubeconfig.injectable";
export interface PrometheusDetails {
prometheusPath: string;
provider: PrometheusProvider;
}
interface PrometheusServicePreferences {
namespace: string;
service: string;
port: number;
prefix: string;
}
interface Dependencies {
readonly prometheusProviders: IComputedValue<PrometheusProvider[]>;
readonly logger: Logger;
getPrometheusProviderByKind: GetPrometheusProviderByKind;
loadProxyKubeconfig: LoadProxyKubeconfig;
}
export interface ClusterPrometheusHandler {
setupPrometheus(preferences?: ClusterPrometheusPreferences): void;
getPrometheusDetails(): Promise<PrometheusDetails>;
}
const ensurePrometheusPath = ({ service, namespace, port }: PrometheusService) => `${namespace}/services/${service}:${port}`;
export const createClusterPrometheusHandler = (...args: [Dependencies, Cluster]): ClusterPrometheusHandler => {
const [deps, cluster] = args;
const {
getPrometheusProviderByKind,
loadProxyKubeconfig,
logger,
prometheusProviders,
} = deps;
let prometheusProvider: string | undefined = undefined;
let prometheus: PrometheusServicePreferences | undefined = undefined;
const setupPrometheus: ClusterPrometheusHandler["setupPrometheus"] = (preferences = {}) => {
prometheusProvider = preferences.prometheusProvider?.type;
prometheus = preferences.prometheus;
};
const ensurePrometheusProvider = (service: PrometheusService) => {
if (!prometheusProvider) {
logger.info(`[CONTEXT-HANDLER]: using ${service.kind} as prometheus provider for clusterId=${cluster.id}`);
prometheusProvider = service.kind;
}
return getPrometheusProviderByKind(prometheusProvider);
};
const listPotentialProviders = () => {
if (prometheusProvider) {
const provider = getPrometheusProviderByKind(prometheusProvider);
if (provider) {
return [provider];
}
}
return prometheusProviders.get();
};
const getPrometheusService = async (): Promise<PrometheusService> => {
setupPrometheus(cluster.preferences);
if (prometheus && prometheusProvider) {
return {
kind: prometheusProvider,
namespace: prometheus.namespace,
service: prometheus.service,
port: prometheus.port,
};
}
const providers = listPotentialProviders();
const proxyConfig = await loadProxyKubeconfig();
const apiClient = proxyConfig.makeApiClient(CoreV1Api);
const potentialServices = await Promise.allSettled(
providers.map(provider => provider.getPrometheusService(apiClient)),
);
const errors = [];
for (const res of potentialServices) {
switch (res.status) {
case "rejected":
errors.push(res.reason);
break;
case "fulfilled":
if (res.value) {
return res.value;
}
}
}
throw new Error("No Prometheus service found", { cause: errors });
};
const getPrometheusDetails: ClusterPrometheusHandler["getPrometheusDetails"] = async () => {
const service = await getPrometheusService();
const prometheusPath = ensurePrometheusPath(service);
const provider = ensurePrometheusProvider(service);
return { prometheusPath, provider };
};
setupPrometheus(cluster.preferences);
return {
setupPrometheus,
getPrometheusDetails,
};
};
@@ -0,0 +1,23 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import type { Cluster } from "../../common/cluster/cluster";
import kubeconfigManagerInjectable from "../kubeconfig-manager/kubeconfig-manager.injectable";
export type RemoveProxyKubeconfig = () => Promise<void>;
const removeProxyKubeconfigInjectable = getInjectable({
id: "remove-proxy-kubeconfig",
instantiate: (di, cluster): RemoveProxyKubeconfig => {
const proxyKubeconfigManager = di.inject(kubeconfigManagerInjectable, cluster);
return () => proxyKubeconfigManager.clear();
},
lifecycle: lifecycleEnum.keyedSingleton({
getInstanceKey: (di, cluster: Cluster) => cluster.id,
}),
});
export default removeProxyKubeconfigInjectable;
@@ -12,6 +12,7 @@ import { withConcurrencyLimit } from "../../common/utils/with-concurrency-limit"
import requestKubeApiResourcesForInjectable from "./request-kube-api-resources-for.injectable";
import type { AsyncResult } from "../../common/utils/async-result";
import { backoffCaller } from "../../common/utils/backoff-caller";
import broadcastConnectionUpdateInjectable from "./broadcast-connection-update.injectable";
export type RequestApiResources = (cluster: Cluster) => Promise<AsyncResult<KubeApiResource[], Error>>;
@@ -29,6 +30,7 @@ const requestApiResourcesInjectable = getInjectable({
return async (...args) => {
const [cluster] = args;
const broadcastConnectionUpdate = di.inject(broadcastConnectionUpdateInjectable, cluster);
const requestKubeApiResources = withConcurrencyLimit(5)(requestKubeApiResourcesFor(cluster));
const groupLists: KubeResourceListGroup[] = [];
@@ -36,7 +38,10 @@ const requestApiResourcesInjectable = getInjectable({
for (const apiVersionRequester of apiVersionRequesters) {
const result = await backoffCaller(() => apiVersionRequester(cluster), {
onIntermediateError: (error, attempt) => {
cluster.broadcastConnectUpdate(`Failed to list kube API resource kinds, attempt ${attempt}: ${error}`, "warning");
broadcastConnectionUpdate({
message: `Failed to list kube API resource kinds, attempt ${attempt}: ${error}`,
level: "warning",
});
logger.warn(`[LIST-API-RESOURCES]: failed to list kube api resources: ${error}`, { attempt, clusterId: cluster.id });
},
});
@@ -56,7 +61,10 @@ const requestApiResourcesInjectable = getInjectable({
for (const result of results) {
if (!result.callWasSuccessful) {
cluster.broadcastConnectUpdate(`Kube APIs under "${result.listGroup.path}" may not be displayed`, "warning");
broadcastConnectionUpdate({
message: `Kube APIs under "${result.listGroup.path}" may not be displayed`,
level: "warning",
});
continue;
}
@@ -17,6 +17,10 @@ interface Pre500WorkspaceStoreModel {
}[];
}
interface Pre500ClusterModel extends ClusterModel {
workspace?: string;
}
const v500Beta10ClusterStoreMigrationInjectable = getInjectable({
id: "v5.0.0-beta.10-cluster-store-migration",
instantiate: (di) => {
@@ -35,7 +39,7 @@ const v500Beta10ClusterStoreMigrationInjectable = getInjectable({
workspaces.set(id, name);
}
const clusters = (store.get("clusters") ?? []) as ClusterModel[];
const clusters = (store.get("clusters") ?? []) as Pre500ClusterModel[];
for (const cluster of clusters) {
if (cluster.workspace) {
@@ -8,6 +8,15 @@ import { moveSync, removeSync } from "fs-extra";
import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
import { isDefined } from "../../../common/utils";
import joinPathsInjectable from "../../../common/path/join-paths.injectable";
import { getInjectable } from "@ogre-tools/injectable";
import loggerInjectable from "../../../common/logger.injectable";
import { clusterStoreMigrationInjectionToken } from "../../../common/cluster-store/migration-token";
import { generateNewIdFor } from "../../../common/utils/generate-new-id-for";
interface Pre500ClusterModel extends ClusterModel {
workspace?: string;
workspaces?: string[];
}
function mergePrometheusPreferences(left: ClusterPrometheusPreferences, right: ClusterPrometheusPreferences): ClusterPrometheusPreferences {
if (left.prometheus && left.prometheusProvider) {
@@ -27,24 +36,15 @@ function mergePrometheusPreferences(left: ClusterPrometheusPreferences, right: C
return {};
}
function mergePreferences(left: ClusterPreferences, right: ClusterPreferences): ClusterPreferences {
return {
terminalCWD: left.terminalCWD || right.terminalCWD || undefined,
clusterName: left.clusterName || right.clusterName || undefined,
iconOrder: left.iconOrder || right.iconOrder || undefined,
icon: left.icon || right.icon || undefined,
httpsProxy: left.httpsProxy || right.httpsProxy || undefined,
hiddenMetrics: mergeSet(left.hiddenMetrics ?? [], right.hiddenMetrics ?? []),
...mergePrometheusPreferences(left, right),
};
}
function mergeLabels(left: Record<string, string>, right: Record<string, string>): Record<string, string> {
return {
...right,
...left,
};
}
const mergePreferences = (left: ClusterPreferences, right: ClusterPreferences): ClusterPreferences => ({
terminalCWD: left.terminalCWD || right.terminalCWD || undefined,
clusterName: left.clusterName || right.clusterName || undefined,
iconOrder: left.iconOrder || right.iconOrder || undefined,
icon: left.icon || right.icon || undefined,
httpsProxy: left.httpsProxy || right.httpsProxy || undefined,
hiddenMetrics: mergeSet(left.hiddenMetrics ?? [], right.hiddenMetrics ?? []),
...mergePrometheusPreferences(left, right),
});
function mergeSet(...iterables: Iterable<string | undefined>[]): string[] {
const res = new Set<string>();
@@ -60,24 +60,17 @@ function mergeSet(...iterables: Iterable<string | undefined>[]): string[] {
return [...res];
}
function mergeClusterModel(prev: ClusterModel, right: Omit<ClusterModel, "id">): ClusterModel {
return {
id: prev.id,
kubeConfigPath: prev.kubeConfigPath,
contextName: prev.contextName,
preferences: mergePreferences(prev.preferences ?? {}, right.preferences ?? {}),
metadata: prev.metadata,
labels: mergeLabels(prev.labels ?? {}, right.labels ?? {}),
accessibleNamespaces: mergeSet(prev.accessibleNamespaces ?? [], right.accessibleNamespaces ?? []),
workspace: prev.workspace || right.workspace,
workspaces: mergeSet([prev.workspace, right.workspace], prev.workspaces ?? [], right.workspaces ?? []),
};
}
import { getInjectable } from "@ogre-tools/injectable";
import loggerInjectable from "../../../common/logger.injectable";
import { clusterStoreMigrationInjectionToken } from "../../../common/cluster-store/migration-token";
import { generateNewIdFor } from "../../../common/utils/generate-new-id-for";
const mergeClusterModel = (prev: Pre500ClusterModel, right: Omit<Pre500ClusterModel, "id">): Pre500ClusterModel => ({
id: prev.id,
kubeConfigPath: prev.kubeConfigPath,
contextName: prev.contextName,
preferences: mergePreferences(prev.preferences ?? {}, right.preferences ?? {}),
metadata: prev.metadata,
labels: { ...(right.labels ?? {}), ...(prev.labels ?? {}) },
accessibleNamespaces: mergeSet(prev.accessibleNamespaces ?? [], right.accessibleNamespaces ?? []),
workspace: prev.workspace || right.workspace,
workspaces: mergeSet([prev.workspace, right.workspace], prev.workspaces ?? [], right.workspaces ?? []),
});
const v500Beta13ClusterStoreMigrationInjectable = getInjectable({
id: "v5.0.0-beta.13-cluster-store-migration",
@@ -104,8 +97,8 @@ const v500Beta13ClusterStoreMigrationInjectable = getInjectable({
version: "5.0.0-beta.13",
run(store) {
const folder = joinPaths(userDataPath, "lens-local-storage");
const oldClusters = (store.get("clusters") ?? []) as ClusterModel[];
const clusters = new Map<string, ClusterModel>();
const oldClusters = (store.get("clusters") ?? []) as Pre500ClusterModel[];
const clusters = new Map<string, Pre500ClusterModel>();
for (const { id: oldId, ...cluster } of oldClusters) {
const newId = generateNewIdFor(cluster);
@@ -3,6 +3,7 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { toJS } from "mobx";
import type { KubernetesCluster } from "../../common/catalog-entities";
import { ClusterMetadataKey } from "../../common/cluster-types";
import type { Cluster } from "../../common/cluster/cluster";
@@ -17,10 +18,10 @@ const updateEntityMetadataInjectable = getInjectable({
return (entity, cluster) => {
entity.metadata.labels = {
...entity.metadata.labels,
...cluster.labels,
...toJS(cluster.labels),
};
entity.metadata.distro = cluster.distribution;
entity.metadata.kubeVersion = cluster.version;
entity.metadata.distro = cluster.distribution.get();
entity.metadata.kubeVersion = cluster.version.get();
enumKeys(ClusterMetadataKey).forEach((key) => {
const metadataKey = ClusterMetadataKey[key];
@@ -7,8 +7,8 @@ import appPathsStateInjectable from "../../common/app-paths/app-paths-state.inje
import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
import { KubernetesCluster } from "../../common/catalog-entities";
import { ClusterMetadataKey } from "../../common/cluster-types";
import type { Cluster } from "../../common/cluster/cluster";
import { createClusterInjectionToken } from "../../common/cluster/create-cluster-injection-token";
import { Cluster } from "../../common/cluster/cluster";
import { replaceObservableObject } from "../../common/utils/replace-observable-object";
import { getDiForUnitTesting } from "../getDiForUnitTesting";
import type { UpdateEntityMetadata } from "./update-entity-metadata.injectable";
import updateEntityMetadataInjectable from "./update-entity-metadata.injectable";
@@ -27,11 +27,10 @@ describe("update-entity-metadata", () => {
get: () => ({} as AppPaths),
set: () => {},
}));
const createCluster = di.inject(createClusterInjectionToken);
updateEntityMetadata = di.inject(updateEntityMetadataInjectable);
cluster = createCluster({
cluster = new Cluster({
id: "some-id",
contextName: "some-context",
kubeConfigPath: "minikube-config.yml",
@@ -50,10 +49,6 @@ describe("update-entity-metadata", () => {
},
};
cluster.metadata = {
...cluster.metadata,
};
entity = new KubernetesCluster({
metadata: {
uid: "some-uid",
@@ -125,9 +120,9 @@ describe("update-entity-metadata", () => {
});
it("given cluster has labels, updates entity metadata with labels", () => {
cluster.labels = {
replaceObservableObject(cluster.labels, {
"some-label": "some-value",
};
});
entity.metadata.labels = {
"some-other-label": "some-other-value",
};
@@ -139,9 +134,9 @@ describe("update-entity-metadata", () => {
});
it("given cluster has labels, overwrites entity metadata with cluster labels", () => {
cluster.labels = {
replaceObservableObject(cluster.labels, {
"some-label": "some-cluster-value",
};
});
entity.metadata.labels = {
"some-label": "some-entity-value",
};
@@ -6,8 +6,7 @@ import type { AppPaths } from "../../common/app-paths/app-path-injection-token";
import appPathsStateInjectable from "../../common/app-paths/app-paths-state.injectable";
import directoryForUserDataInjectable from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
import { KubernetesCluster } from "../../common/catalog-entities";
import type { Cluster } from "../../common/cluster/cluster";
import { createClusterInjectionToken } from "../../common/cluster/create-cluster-injection-token";
import { Cluster } from "../../common/cluster/cluster";
import { getDiForUnitTesting } from "../getDiForUnitTesting";
import type { UpdateEntitySpec } from "./update-entity-spec.injectable";
import updateEntitySpecInjectable from "./update-entity-spec.injectable";
@@ -25,11 +24,10 @@ describe("update-entity-spec", () => {
get: () => ({} as AppPaths),
set: () => {},
}));
const createCluster = di.inject(createClusterInjectionToken);
updateEntitySpec = di.inject(updateEntitySpecInjectable);
cluster = createCluster({
cluster = new Cluster({
id: "some-id",
contextName: "some-context",
kubeConfigPath: "minikube-config.yml",