mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Migrating Vue components to React and stores refactoring (#585)
Signed-off-by: Roman <ixrock@gmail.com> Co-authored-by: Sebastian Malton <sebastian@malton.name> Co-authored-by: Sebastian Malton <smalton@mirantis.com> Co-authored-by: Lauri Nevala <lauri.nevala@gmail.com> Co-authored-by: Alex Andreev <alex.andreev.email@gmail.com>
This commit is contained in:
297 files changed
+8186
-9103
No files matched your search
+36
-252
@@ -1,281 +1,65 @@
|
||||
import { KubeConfig } from "@kubernetes/client-node"
|
||||
import { PromiseIpc } from "electron-promise-ipc"
|
||||
import http from "http"
|
||||
import { Cluster, ClusterBaseInfo } from "./cluster"
|
||||
import { clusterStore } from "../common/cluster-store"
|
||||
import * as k8s from "./k8s"
|
||||
import logger from "./logger"
|
||||
import { LensProxy } from "./proxy"
|
||||
import { app } from "electron"
|
||||
import path from "path"
|
||||
import { promises } from "fs"
|
||||
import { ensureDir } from "fs-extra"
|
||||
import filenamify from "filenamify"
|
||||
import { v4 as uuid } from "uuid"
|
||||
import { apiPrefix } from "../common/vars";
|
||||
|
||||
export type FeatureInstallRequest = {
|
||||
name: string;
|
||||
clusterId: string;
|
||||
config: any;
|
||||
}
|
||||
|
||||
export type FeatureInstallResponse = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type ClusterIconUpload = {
|
||||
path: string;
|
||||
name: string;
|
||||
clusterId: string;
|
||||
}
|
||||
import "../common/cluster-ipc";
|
||||
import type http from "http"
|
||||
import { autorun } from "mobx";
|
||||
import { ClusterId, clusterStore } from "../common/cluster-store"
|
||||
import { Cluster } from "./cluster"
|
||||
import logger from "./logger";
|
||||
import { apiKubePrefix } from "../common/vars";
|
||||
|
||||
export class ClusterManager {
|
||||
public static readonly clusterIconDir = path.join(app.getPath("userData"), "icons")
|
||||
protected promiseIpc: any
|
||||
protected proxyServer: LensProxy
|
||||
protected port: number
|
||||
protected clusters: Map<string, Cluster>;
|
||||
|
||||
constructor(clusters: Cluster[], port: number) {
|
||||
this.promiseIpc = new PromiseIpc({ timeout: 2000 })
|
||||
this.port = port
|
||||
this.clusters = new Map()
|
||||
clusters.forEach((clusterInfo) => {
|
||||
try {
|
||||
const kc = this.loadKubeConfig(clusterInfo.kubeConfigPath)
|
||||
const cluster = new Cluster({
|
||||
id: clusterInfo.id,
|
||||
port: this.port,
|
||||
kubeConfigPath: clusterInfo.kubeConfigPath,
|
||||
contextName: clusterInfo.contextName,
|
||||
preferences: clusterInfo.preferences,
|
||||
workspace: clusterInfo.workspace
|
||||
})
|
||||
cluster.init(kc)
|
||||
logger.debug(`Created cluster[id: ${ cluster.id }] for context ${ cluster.contextName }`)
|
||||
this.clusters.set(cluster.id, cluster)
|
||||
} catch(error) {
|
||||
logger.error(`Error while initializing ${clusterInfo.contextName}`)
|
||||
}
|
||||
constructor(public readonly port: number) {
|
||||
// auto-init clusters
|
||||
autorun(() => {
|
||||
clusterStore.clusters.forEach(cluster => {
|
||||
if (!cluster.initialized) {
|
||||
logger.info(`[CLUSTER-MANAGER]: init cluster`, cluster.getMeta());
|
||||
cluster.init(port);
|
||||
}
|
||||
});
|
||||
});
|
||||
logger.debug("clusters after constructor:" + this.clusters.size)
|
||||
this.listenEvents()
|
||||
}
|
||||
|
||||
public getClusters() {
|
||||
return this.clusters.values()
|
||||
}
|
||||
|
||||
public getCluster(id: string) {
|
||||
return this.clusters.get(id)
|
||||
}
|
||||
|
||||
public stop() {
|
||||
const clusters = Array.from(this.getClusters())
|
||||
clusters.map(cluster => cluster.stopServer())
|
||||
}
|
||||
|
||||
protected loadKubeConfig(configPath: string): KubeConfig {
|
||||
const kc = new KubeConfig();
|
||||
kc.loadFromFile(configPath)
|
||||
return kc;
|
||||
}
|
||||
|
||||
protected async addNewCluster(clusterData: ClusterBaseInfo): Promise<Cluster> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const kc = this.loadKubeConfig(clusterData.kubeConfigPath)
|
||||
k8s.validateConfig(kc)
|
||||
kc.setCurrentContext(clusterData.contextName)
|
||||
const cluster = new Cluster({
|
||||
id: uuid(),
|
||||
port: this.port,
|
||||
kubeConfigPath: clusterData.kubeConfigPath,
|
||||
contextName: clusterData.contextName,
|
||||
preferences: clusterData.preferences,
|
||||
workspace: clusterData.workspace
|
||||
})
|
||||
cluster.init(kc)
|
||||
cluster.save()
|
||||
this.clusters.set(cluster.id, cluster)
|
||||
resolve(cluster)
|
||||
|
||||
} catch(error) {
|
||||
logger.error(error)
|
||||
reject(error)
|
||||
// auto-stop removed clusters
|
||||
autorun(() => {
|
||||
const removedClusters = Array.from(clusterStore.removedClusters.values());
|
||||
if (removedClusters.length > 0) {
|
||||
const meta = removedClusters.map(cluster => cluster.getMeta());
|
||||
logger.info(`[CLUSTER-MANAGER]: removing clusters`, meta);
|
||||
removedClusters.forEach(cluster => cluster.disconnect());
|
||||
clusterStore.removedClusters.clear();
|
||||
}
|
||||
}, {
|
||||
delay: 250
|
||||
});
|
||||
}
|
||||
|
||||
protected listenEvents() {
|
||||
this.promiseIpc.on("addCluster", async (clusterData: ClusterBaseInfo) => {
|
||||
logger.debug(`IPC: addCluster`)
|
||||
const cluster = await this.addNewCluster(clusterData)
|
||||
return {
|
||||
addedCluster: cluster.toClusterInfo(),
|
||||
allClusters: Array.from(this.getClusters()).map((cluster: Cluster) => cluster.toClusterInfo())
|
||||
}
|
||||
});
|
||||
|
||||
this.promiseIpc.on("getClusters", async (workspaceId: string) => {
|
||||
logger.debug(`IPC: getClusters, workspace ${workspaceId}`)
|
||||
const workspaceClusters = Array.from(this.getClusters()).filter((cluster) => cluster.workspace === workspaceId)
|
||||
return workspaceClusters.map((cluster: Cluster) => cluster.toClusterInfo())
|
||||
});
|
||||
|
||||
this.promiseIpc.on("getCluster", async (id: string) => {
|
||||
logger.debug(`IPC: getCluster`)
|
||||
const cluster = this.getCluster(id)
|
||||
if (cluster) {
|
||||
await cluster.refreshCluster()
|
||||
return cluster.toClusterInfo()
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
});
|
||||
|
||||
this.promiseIpc.on("installFeature", async (installReq: FeatureInstallRequest) => {
|
||||
logger.debug(`IPC: installFeature for ${installReq.name}`)
|
||||
const cluster = this.clusters.get(installReq.clusterId)
|
||||
try {
|
||||
await cluster.installFeature(installReq.name, installReq.config)
|
||||
return {success: true, message: ""}
|
||||
} catch(error) {
|
||||
return {success: false, message: error}
|
||||
}
|
||||
});
|
||||
|
||||
this.promiseIpc.on("upgradeFeature", async (installReq: FeatureInstallRequest) => {
|
||||
logger.debug(`IPC: upgradeFeature for ${installReq.name}`)
|
||||
const cluster = this.clusters.get(installReq.clusterId)
|
||||
try {
|
||||
await cluster.upgradeFeature(installReq.name, installReq.config)
|
||||
return {success: true, message: ""}
|
||||
} catch(error) {
|
||||
return {success: false, message: error}
|
||||
}
|
||||
});
|
||||
|
||||
this.promiseIpc.on("uninstallFeature", async (installReq: FeatureInstallRequest) => {
|
||||
logger.debug(`IPC: uninstallFeature for ${installReq.name}`)
|
||||
const cluster = this.clusters.get(installReq.clusterId)
|
||||
|
||||
await cluster.uninstallFeature(installReq.name)
|
||||
return {success: true, message: ""}
|
||||
});
|
||||
|
||||
this.promiseIpc.on("saveClusterIcon", async (fileUpload: ClusterIconUpload) => {
|
||||
logger.debug(`IPC: saveClusterIcon for ${fileUpload.clusterId}`)
|
||||
const cluster = this.getCluster(fileUpload.clusterId)
|
||||
if (!cluster) {
|
||||
return {success: false, message: "Cluster not found"}
|
||||
}
|
||||
try {
|
||||
const clusterIcon = await this.uploadClusterIcon(cluster, fileUpload.name, fileUpload.path)
|
||||
clusterStore.reloadCluster(cluster);
|
||||
if(!cluster.preferences) cluster.preferences = {};
|
||||
cluster.preferences.icon = clusterIcon
|
||||
clusterStore.storeCluster(cluster);
|
||||
return {success: true, cluster: cluster.toClusterInfo(), message: ""}
|
||||
} catch(error) {
|
||||
return {success: false, message: error}
|
||||
}
|
||||
});
|
||||
|
||||
this.promiseIpc.on("resetClusterIcon", async (id: string) => {
|
||||
logger.debug(`IPC: resetClusterIcon`)
|
||||
const cluster = this.getCluster(id)
|
||||
if (cluster && cluster.preferences) {
|
||||
cluster.preferences.icon = null;
|
||||
clusterStore.storeCluster(cluster)
|
||||
return {success: true, cluster: cluster.toClusterInfo(), message: ""}
|
||||
} else {
|
||||
return {success: false, message: "Cluster not found"}
|
||||
}
|
||||
});
|
||||
|
||||
this.promiseIpc.on("refreshCluster", async (clusterId: string) => {
|
||||
const cluster = this.clusters.get(clusterId)
|
||||
await cluster.refreshCluster()
|
||||
return cluster.toClusterInfo()
|
||||
});
|
||||
|
||||
this.promiseIpc.on("stopCluster", (clusterId: string) => {
|
||||
logger.debug(`IPC: stopCluster: ${clusterId}`)
|
||||
const cluster = this.clusters.get(clusterId)
|
||||
if (cluster) {
|
||||
cluster.stopServer()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
});
|
||||
|
||||
this.promiseIpc.on("removeCluster", (ctx: string) => {
|
||||
logger.debug(`IPC: removeCluster: ${ctx}`)
|
||||
return this.removeCluster(ctx).map((cluster: Cluster) => cluster.toClusterInfo())
|
||||
});
|
||||
|
||||
this.promiseIpc.on("clusterStored", (clusterId: string) => {
|
||||
logger.debug(`IPC: clusterStored: ${clusterId}`)
|
||||
const cluster = this.clusters.get(clusterId)
|
||||
if (cluster) {
|
||||
clusterStore.reloadCluster(cluster);
|
||||
cluster.stopServer()
|
||||
}
|
||||
});
|
||||
|
||||
this.promiseIpc.on("preferencesSaved", () => {
|
||||
logger.debug(`IPC: preferencesSaved`)
|
||||
this.clusters.forEach((cluster) => {
|
||||
cluster.stopServer()
|
||||
})
|
||||
});
|
||||
|
||||
this.promiseIpc.on("getClusterEvents", async (clusterId: string) => {
|
||||
const cluster = this.clusters.get(clusterId)
|
||||
return cluster.getEventCount();
|
||||
});
|
||||
|
||||
stop() {
|
||||
clusterStore.clusters.forEach((cluster: Cluster) => {
|
||||
cluster.disconnect();
|
||||
})
|
||||
}
|
||||
|
||||
public removeCluster(id: string): Cluster[] {
|
||||
const cluster = this.clusters.get(id)
|
||||
if (cluster) {
|
||||
cluster.stopServer()
|
||||
clusterStore.removeCluster(cluster.id);
|
||||
this.clusters.delete(cluster.id)
|
||||
}
|
||||
return Array.from(this.clusters.values())
|
||||
protected getCluster(id: ClusterId) {
|
||||
return clusterStore.getById(id);
|
||||
}
|
||||
|
||||
public getClusterForRequest(req: http.IncomingMessage): Cluster {
|
||||
getClusterForRequest(req: http.IncomingMessage): Cluster {
|
||||
let cluster: Cluster = null
|
||||
|
||||
// lens-server is connecting to 127.0.0.1:<port>/<uid>
|
||||
if (req.headers.host.startsWith("127.0.0.1")) {
|
||||
const clusterId = req.url.split("/")[1]
|
||||
if (clusterId) {
|
||||
cluster = this.clusters.get(clusterId)
|
||||
cluster = this.getCluster(clusterId)
|
||||
if (cluster) {
|
||||
// we need to swap path prefix so that request is proxied to kube api
|
||||
req.url = req.url.replace(`/${clusterId}`, apiPrefix.KUBE_BASE)
|
||||
req.url = req.url.replace(`/${clusterId}`, apiKubePrefix)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const id = req.headers.host.split(".")[0]
|
||||
cluster = this.clusters.get(id)
|
||||
cluster = this.getCluster(id)
|
||||
}
|
||||
|
||||
return cluster;
|
||||
}
|
||||
|
||||
protected async uploadClusterIcon(cluster: Cluster, fileName: string, src: string): Promise<string> {
|
||||
await ensureDir(ClusterManager.clusterIconDir)
|
||||
fileName = filenamify(cluster.contextName + "-" + fileName)
|
||||
const dest = path.join(ClusterManager.clusterIconDir, fileName)
|
||||
await promises.copyFile(src, dest)
|
||||
return "store:///icons/" + fileName
|
||||
}
|
||||
}
|
||||
+332
-197
@@ -1,217 +1,267 @@
|
||||
import type { ClusterId, 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"
|
||||
import { action, computed, observable, reaction, toJS, when } from "mobx";
|
||||
import { apiKubePrefix } from "../common/vars";
|
||||
import { broadcastIpc } from "../common/ipc";
|
||||
import { ContextHandler } from "./context-handler"
|
||||
import { FeatureStatusMap } from "./feature"
|
||||
import * as k8s from "./k8s"
|
||||
import { clusterStore } from "../common/cluster-store"
|
||||
import logger from "./logger"
|
||||
import { AuthorizationV1Api, CoreV1Api, KubeConfig, V1ResourceAttributes } from "@kubernetes/client-node"
|
||||
import * as fm from "./feature-manager";
|
||||
import { Kubectl } from "./kubectl";
|
||||
import { KubeconfigManager } from "./kubeconfig-manager"
|
||||
import { PromiseIpc } from "electron-promise-ipc"
|
||||
import request from "request-promise-native"
|
||||
import { apiPrefix } from "../common/vars";
|
||||
import { getNodeWarningConditions, loadConfig, podHasIssues } from "../common/kube-helpers"
|
||||
import { getFeatures, installFeature, uninstallFeature, upgradeFeature } from "./feature-manager";
|
||||
import request, { RequestPromiseOptions } from "request-promise-native"
|
||||
import { apiResources } from "../common/rbac";
|
||||
import logger from "./logger"
|
||||
|
||||
enum ClusterStatus {
|
||||
export enum ClusterStatus {
|
||||
AccessGranted = 2,
|
||||
AccessDenied = 1,
|
||||
Offline = 0
|
||||
}
|
||||
|
||||
export interface ClusterBaseInfo {
|
||||
id: string;
|
||||
kubeConfigPath: string;
|
||||
contextName: string;
|
||||
preferences?: ClusterPreferences;
|
||||
port?: number;
|
||||
workspace?: string;
|
||||
}
|
||||
|
||||
export interface ClusterInfo extends ClusterBaseInfo {
|
||||
url: string;
|
||||
export interface ClusterState extends ClusterModel {
|
||||
initialized: boolean;
|
||||
apiUrl: string;
|
||||
online?: boolean;
|
||||
accessible?: boolean;
|
||||
failureReason?: string;
|
||||
nodes?: number;
|
||||
version?: string;
|
||||
distribution?: string;
|
||||
isAdmin?: boolean;
|
||||
features?: FeatureStatusMap;
|
||||
kubeCtl?: Kubectl;
|
||||
contextName: string;
|
||||
online: boolean;
|
||||
disconnected: boolean;
|
||||
accessible: boolean;
|
||||
failureReason: string;
|
||||
nodes: number;
|
||||
eventCount: number;
|
||||
version: string;
|
||||
distribution: string;
|
||||
isAdmin: boolean;
|
||||
allowedNamespaces: string[]
|
||||
allowedResources: string[]
|
||||
features: FeatureStatusMap;
|
||||
}
|
||||
|
||||
export type ClusterPreferences = {
|
||||
terminalCWD?: string;
|
||||
clusterName?: string;
|
||||
prometheus?: {
|
||||
namespace: string;
|
||||
service: string;
|
||||
port: number;
|
||||
prefix: string;
|
||||
};
|
||||
prometheusProvider?: {
|
||||
type: string;
|
||||
};
|
||||
icon?: string;
|
||||
httpsProxy?: string;
|
||||
}
|
||||
|
||||
export class Cluster implements ClusterInfo {
|
||||
public id: string;
|
||||
public workspace: string;
|
||||
public contextHandler: ContextHandler;
|
||||
public contextName: string;
|
||||
public url: string;
|
||||
public port: number;
|
||||
public apiUrl: string;
|
||||
public online: boolean;
|
||||
public accessible: boolean;
|
||||
public failureReason: string;
|
||||
public nodes: number;
|
||||
public version: string;
|
||||
public distribution: string;
|
||||
public isAdmin: boolean;
|
||||
public features: FeatureStatusMap;
|
||||
export class Cluster implements ClusterModel {
|
||||
public id: ClusterId;
|
||||
public frameId: number;
|
||||
public kubeCtl: Kubectl
|
||||
public kubeConfigPath: string;
|
||||
public eventCount: number;
|
||||
public preferences: ClusterPreferences;
|
||||
|
||||
protected eventPoller: NodeJS.Timeout;
|
||||
protected promiseIpc = new PromiseIpc({ timeout: 2000 })
|
||||
|
||||
public contextHandler: ContextHandler;
|
||||
protected kubeconfigManager: KubeconfigManager;
|
||||
protected eventDisposers: Function[] = [];
|
||||
|
||||
constructor(clusterInfo: ClusterBaseInfo) {
|
||||
if (clusterInfo) Object.assign(this, clusterInfo)
|
||||
if (!this.preferences) this.preferences = {}
|
||||
whenInitialized = when(() => this.initialized);
|
||||
|
||||
@observable initialized = false;
|
||||
@observable contextName: string;
|
||||
@observable workspace: WorkspaceId;
|
||||
@observable kubeConfigPath: string;
|
||||
@observable apiUrl: string; // cluster server url
|
||||
@observable kubeProxyUrl: string; // lens-proxy to kube-api url
|
||||
@observable online: boolean;
|
||||
@observable accessible: boolean;
|
||||
@observable disconnected: boolean;
|
||||
@observable failureReason: string;
|
||||
@observable nodes = 0;
|
||||
@observable version: string;
|
||||
@observable distribution = "unknown";
|
||||
@observable isAdmin = false;
|
||||
@observable eventCount = 0;
|
||||
@observable preferences: ClusterPreferences = {};
|
||||
@observable features: FeatureStatusMap = {};
|
||||
@observable allowedNamespaces: string[] = [];
|
||||
@observable allowedResources: string[] = [];
|
||||
|
||||
@computed get available() {
|
||||
return this.accessible && !this.disconnected;
|
||||
}
|
||||
|
||||
public proxyKubeconfigPath() {
|
||||
return this.kubeconfigManager.getPath()
|
||||
constructor(model: ClusterModel) {
|
||||
this.updateModel(model);
|
||||
}
|
||||
|
||||
public proxyKubeconfig() {
|
||||
const kc = new KubeConfig()
|
||||
kc.loadFromFile(this.proxyKubeconfigPath())
|
||||
return kc
|
||||
@action
|
||||
updateModel(model: ClusterModel) {
|
||||
Object.assign(this, model);
|
||||
this.apiUrl = this.getKubeconfig().getCurrentCluster()?.server;
|
||||
this.contextName = this.contextName || this.preferences.clusterName;
|
||||
}
|
||||
|
||||
public async init(kc: KubeConfig) {
|
||||
this.apiUrl = kc.getCurrentCluster().server
|
||||
this.contextHandler = new ContextHandler(kc, this)
|
||||
await this.contextHandler.init() // So we get the proxy port reserved
|
||||
this.kubeconfigManager = new KubeconfigManager(this)
|
||||
|
||||
this.url = this.contextHandler.url
|
||||
}
|
||||
|
||||
public stopServer() {
|
||||
this.contextHandler.stopServer()
|
||||
clearInterval(this.eventPoller);
|
||||
}
|
||||
|
||||
public async installFeature(name: string, config: any) {
|
||||
await fm.installFeature(name, this, config)
|
||||
return this.refreshCluster()
|
||||
}
|
||||
|
||||
public async upgradeFeature(name: string, config: any) {
|
||||
await fm.upgradeFeature(name, this, config)
|
||||
return this.refreshCluster()
|
||||
}
|
||||
|
||||
public async uninstallFeature(name: string) {
|
||||
await fm.uninstallFeature(name, this)
|
||||
return this.refreshCluster()
|
||||
}
|
||||
|
||||
public async refreshCluster() {
|
||||
clusterStore.reloadCluster(this)
|
||||
this.contextHandler.setClusterPreferences(this.preferences)
|
||||
|
||||
const connectionStatus = await this.getConnectionStatus()
|
||||
this.accessible = connectionStatus == ClusterStatus.AccessGranted;
|
||||
this.online = connectionStatus > ClusterStatus.Offline;
|
||||
|
||||
if (this.accessible) {
|
||||
this.distribution = this.detectKubernetesDistribution(this.version)
|
||||
this.features = await fm.getFeatures(this)
|
||||
this.isAdmin = await this.isClusterAdmin()
|
||||
this.nodes = await this.getNodeCount()
|
||||
this.kubeCtl = new Kubectl(this.version)
|
||||
this.kubeCtl.ensureKubectl()
|
||||
@action
|
||||
async init(port: number) {
|
||||
try {
|
||||
this.contextHandler = new ContextHandler(this);
|
||||
this.kubeconfigManager = new KubeconfigManager(this, this.contextHandler);
|
||||
this.kubeProxyUrl = `http://localhost:${port}${apiKubePrefix}`;
|
||||
this.initialized = true;
|
||||
logger.info(`[CLUSTER]: "${this.contextName}" init success`, {
|
||||
id: this.id,
|
||||
context: this.contextName,
|
||||
apiUrl: this.apiUrl
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`[CLUSTER]: init failed: ${err}`, {
|
||||
id: this.id,
|
||||
error: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected bindEvents() {
|
||||
logger.info(`[CLUSTER]: bind events`, this.getMeta());
|
||||
const refreshTimer = setInterval(() => this.online && this.refresh(), 30000); // every 30s
|
||||
const refreshEventsTimer = setInterval(() => this.online && this.refreshEvents(), 3000); // every 3s
|
||||
|
||||
this.eventDisposers.push(
|
||||
reaction(this.getState, this.pushState),
|
||||
() => clearInterval(refreshTimer),
|
||||
() => clearInterval(refreshEventsTimer),
|
||||
);
|
||||
}
|
||||
|
||||
protected unbindEvents() {
|
||||
logger.info(`[CLUSTER]: unbind events`, this.getMeta());
|
||||
this.eventDisposers.forEach(dispose => dispose());
|
||||
this.eventDisposers.length = 0;
|
||||
}
|
||||
|
||||
async activate() {
|
||||
logger.info(`[CLUSTER]: activate`, this.getMeta());
|
||||
await this.whenInitialized;
|
||||
if (!this.eventDisposers.length) {
|
||||
this.bindEvents();
|
||||
}
|
||||
if (this.disconnected) {
|
||||
await this.reconnect();
|
||||
}
|
||||
await this.refresh();
|
||||
return this.pushState();
|
||||
}
|
||||
|
||||
async reconnect() {
|
||||
logger.info(`[CLUSTER]: reconnect`, this.getMeta());
|
||||
this.contextHandler.stopServer();
|
||||
await this.contextHandler.ensureServer();
|
||||
this.disconnected = false;
|
||||
}
|
||||
|
||||
@action
|
||||
disconnect() {
|
||||
logger.info(`[CLUSTER]: disconnect`, this.getMeta());
|
||||
this.unbindEvents();
|
||||
this.contextHandler.stopServer();
|
||||
this.disconnected = true;
|
||||
this.online = false;
|
||||
this.accessible = false;
|
||||
this.pushState();
|
||||
}
|
||||
|
||||
@action
|
||||
async refresh() {
|
||||
logger.info(`[CLUSTER]: refresh`, this.getMeta());
|
||||
await this.refreshConnectionStatus(); // refresh "version", "online", etc.
|
||||
if (this.accessible) {
|
||||
this.kubeCtl = new Kubectl(this.version)
|
||||
this.distribution = this.detectKubernetesDistribution(this.version)
|
||||
const [features, isAdmin, nodesCount] = await Promise.all([
|
||||
getFeatures(this),
|
||||
this.isClusterAdmin(),
|
||||
this.getNodeCount(),
|
||||
this.kubeCtl.ensureKubectl()
|
||||
]);
|
||||
this.features = features;
|
||||
this.isAdmin = isAdmin;
|
||||
this.nodes = nodesCount;
|
||||
await Promise.all([
|
||||
this.refreshEvents(),
|
||||
this.refreshAllowedResources(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
async refreshConnectionStatus() {
|
||||
const connectionStatus = await this.getConnectionStatus();
|
||||
this.online = connectionStatus > ClusterStatus.Offline;
|
||||
this.accessible = connectionStatus == ClusterStatus.AccessGranted;
|
||||
}
|
||||
|
||||
@action
|
||||
async refreshAllowedResources() {
|
||||
this.allowedNamespaces = await this.getAllowedNamespaces();
|
||||
this.allowedResources = await this.getAllowedResources();
|
||||
}
|
||||
|
||||
@action
|
||||
async refreshEvents() {
|
||||
this.eventCount = await this.getEventCount();
|
||||
}
|
||||
|
||||
public getPrometheusApiPrefix() {
|
||||
if (!this.preferences.prometheus?.prefix) {
|
||||
return ""
|
||||
}
|
||||
return this.preferences.prometheus.prefix
|
||||
protected getKubeconfig(): KubeConfig {
|
||||
return loadConfig(this.kubeConfigPath);
|
||||
}
|
||||
|
||||
public save() {
|
||||
clusterStore.storeCluster(this)
|
||||
getProxyKubeconfig(): KubeConfig {
|
||||
return loadConfig(this.getProxyKubeconfigPath());
|
||||
}
|
||||
|
||||
public toClusterInfo(): ClusterInfo {
|
||||
return {
|
||||
id: this.id,
|
||||
workspace: this.workspace,
|
||||
url: this.url,
|
||||
contextName: this.contextName,
|
||||
apiUrl: this.apiUrl,
|
||||
online: this.online,
|
||||
accessible: this.accessible,
|
||||
failureReason: this.failureReason,
|
||||
nodes: this.nodes,
|
||||
version: this.version,
|
||||
distribution: this.distribution,
|
||||
isAdmin: this.isAdmin,
|
||||
features: this.features,
|
||||
kubeCtl: this.kubeCtl,
|
||||
kubeConfigPath: this.kubeConfigPath,
|
||||
preferences: this.preferences
|
||||
}
|
||||
getProxyKubeconfigPath(): string {
|
||||
return this.kubeconfigManager.getPath()
|
||||
}
|
||||
|
||||
protected async k8sRequest(path: string, opts?: request.RequestPromiseOptions) {
|
||||
const options = Object.assign({
|
||||
async installFeature(name: string, config: any) {
|
||||
return installFeature(name, this, config)
|
||||
}
|
||||
|
||||
async upgradeFeature(name: string, config: any) {
|
||||
return upgradeFeature(name, this, config)
|
||||
}
|
||||
|
||||
async uninstallFeature(name: string) {
|
||||
return uninstallFeature(name, this)
|
||||
}
|
||||
|
||||
protected async k8sRequest<T = any>(path: string, options: RequestPromiseOptions = {}): Promise<T> {
|
||||
const apiUrl = this.kubeProxyUrl + path;
|
||||
return request(apiUrl, {
|
||||
json: true,
|
||||
timeout: 10000
|
||||
}, (opts || {}))
|
||||
if (!options.headers) { options.headers = {} }
|
||||
options.headers.host = `${this.id}.localhost:${this.port}`
|
||||
return request(`http://127.0.0.1:${this.port}${apiPrefix.KUBE_BASE}${path}`, options)
|
||||
timeout: 5000,
|
||||
...options,
|
||||
headers: {
|
||||
Host: `${this.id}.${new URL(this.kubeProxyUrl).host}`, // required in ClusterManager.getClusterForRequest()
|
||||
...(options.headers || {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
protected async getConnectionStatus() {
|
||||
getMetrics(prometheusPath: string, queryParams: IMetricsReqParams & { query: string }) {
|
||||
const prometheusPrefix = this.preferences.prometheus?.prefix || "";
|
||||
const metricsPath = `/api/v1/namespaces/${prometheusPath}/proxy${prometheusPrefix}/api/v1/query_range`;
|
||||
return this.k8sRequest(metricsPath, {
|
||||
timeout: 0,
|
||||
resolveWithFullResponse: false,
|
||||
json: true,
|
||||
qs: queryParams,
|
||||
})
|
||||
}
|
||||
|
||||
protected async getConnectionStatus(): Promise<ClusterStatus> {
|
||||
try {
|
||||
const response = await this.k8sRequest("/version")
|
||||
this.version = response.gitVersion
|
||||
this.failureReason = null
|
||||
return ClusterStatus.AccessGranted;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to connect to cluster ${this.contextName}: ${JSON.stringify(error)}`)
|
||||
logger.error(`Failed to connect cluster "${this.contextName}": ${error}`)
|
||||
if (error.statusCode) {
|
||||
if (error.statusCode >= 400 && error.statusCode < 500) {
|
||||
this.failureReason = "Invalid credentials";
|
||||
return ClusterStatus.AccessDenied;
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
this.failureReason = error.error || error.message;
|
||||
return ClusterStatus.Offline;
|
||||
}
|
||||
}
|
||||
else if (error.failed === true) {
|
||||
} else if (error.failed === true) {
|
||||
if (error.timedOut === true) {
|
||||
this.failureReason = "Connection timed out";
|
||||
return ClusterStatus.Offline;
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
this.failureReason = "Failed to fetch credentials";
|
||||
return ClusterStatus.AccessDenied;
|
||||
}
|
||||
@@ -221,22 +271,22 @@ export class Cluster implements ClusterInfo {
|
||||
}
|
||||
}
|
||||
|
||||
public async canI(resourceAttributes: V1ResourceAttributes): Promise<boolean> {
|
||||
const authApi = this.proxyKubeconfig().makeApiClient(AuthorizationV1Api)
|
||||
async canI(resourceAttributes: V1ResourceAttributes): Promise<boolean> {
|
||||
const authApi = this.getProxyKubeconfig().makeApiClient(AuthorizationV1Api)
|
||||
try {
|
||||
const accessReview = await authApi.createSelfSubjectAccessReview({
|
||||
apiVersion: "authorization.k8s.io/v1",
|
||||
kind: "SelfSubjectAccessReview",
|
||||
spec: { resourceAttributes }
|
||||
})
|
||||
return accessReview.body.status.allowed === true
|
||||
return accessReview.body.status.allowed
|
||||
} catch (error) {
|
||||
logger.error(`failed to request selfSubjectAccessReview: ${error.message}`)
|
||||
logger.error(`failed to request selfSubjectAccessReview: ${error}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
protected async isClusterAdmin(): Promise<boolean> {
|
||||
async isClusterAdmin(): Promise<boolean> {
|
||||
return this.canI({
|
||||
namespace: "kube-system",
|
||||
resource: "*",
|
||||
@@ -245,32 +295,17 @@ export class Cluster implements ClusterInfo {
|
||||
}
|
||||
|
||||
protected detectKubernetesDistribution(kubernetesVersion: string): string {
|
||||
if (kubernetesVersion.includes("gke")) {
|
||||
return "gke"
|
||||
}
|
||||
else if (kubernetesVersion.includes("eks")) {
|
||||
return "eks"
|
||||
}
|
||||
else if (kubernetesVersion.includes("IKS")) {
|
||||
return "iks"
|
||||
}
|
||||
else if (this.apiUrl.endsWith("azmk8s.io")) {
|
||||
return "aks"
|
||||
}
|
||||
else if (this.apiUrl.endsWith("k8s.ondigitalocean.com")) {
|
||||
return "digitalocean"
|
||||
}
|
||||
else if (this.contextHandler.contextName.startsWith("minikube")) {
|
||||
return "minikube"
|
||||
}
|
||||
else if (kubernetesVersion.includes("+")) {
|
||||
return "custom"
|
||||
}
|
||||
|
||||
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() {
|
||||
protected async getNodeCount(): Promise<number> {
|
||||
try {
|
||||
const response = await this.k8sRequest("/api/v1/nodes")
|
||||
return response.items.length
|
||||
@@ -280,11 +315,11 @@ export class Cluster implements ClusterInfo {
|
||||
}
|
||||
}
|
||||
|
||||
public async getEventCount(): Promise<number> {
|
||||
protected async getEventCount(): Promise<number> {
|
||||
if (!this.isAdmin) {
|
||||
return 0;
|
||||
}
|
||||
const client = this.proxyKubeconfig().makeApiClient(CoreV1Api);
|
||||
const client = this.getProxyKubeconfig().makeApiClient(CoreV1Api);
|
||||
try {
|
||||
const response = await client.listEventForAllNamespaces(false, null, null, null, 1000);
|
||||
const uniqEventSources = new Set();
|
||||
@@ -294,20 +329,19 @@ export class Cluster implements ClusterInfo {
|
||||
try {
|
||||
const pod = (await client.readNamespacedPod(w.involvedObject.name, w.involvedObject.namespace)).body;
|
||||
logger.debug(`checking pod ${w.involvedObject.namespace}/${w.involvedObject.name}`)
|
||||
if (k8s.podHasIssues(pod)) {
|
||||
if (podHasIssues(pod)) {
|
||||
uniqEventSources.add(w.involvedObject.uid);
|
||||
}
|
||||
} catch (err) {
|
||||
}
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
uniqEventSources.add(w.involvedObject.uid);
|
||||
}
|
||||
}
|
||||
let nodeNotificationCount = 0;
|
||||
const nodes = (await client.listNode()).body.items;
|
||||
nodes.map(n => {
|
||||
nodeNotificationCount = nodeNotificationCount + k8s.getNodeWarningConditions(n).length
|
||||
nodeNotificationCount = nodeNotificationCount + getNodeWarningConditions(n).length
|
||||
});
|
||||
return uniqEventSources.size + nodeNotificationCount;
|
||||
} catch (error) {
|
||||
@@ -315,4 +349,105 @@ export class Cluster implements ClusterInfo {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
toJSON(): ClusterModel {
|
||||
const model: ClusterModel = {
|
||||
id: this.id,
|
||||
contextName: this.contextName,
|
||||
kubeConfigPath: this.kubeConfigPath,
|
||||
workspace: this.workspace,
|
||||
preferences: this.preferences,
|
||||
};
|
||||
return toJS(model, {
|
||||
recurseEverything: true
|
||||
})
|
||||
}
|
||||
|
||||
// serializable cluster-state used for sync btw main <-> renderer
|
||||
getState = (): ClusterState => {
|
||||
const state: ClusterState = {
|
||||
...this.toJSON(),
|
||||
initialized: this.initialized,
|
||||
apiUrl: this.apiUrl,
|
||||
online: this.online,
|
||||
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,
|
||||
allowedNamespaces: this.allowedNamespaces,
|
||||
allowedResources: this.allowedResources,
|
||||
};
|
||||
return toJS(state, {
|
||||
recurseEverything: true
|
||||
})
|
||||
}
|
||||
|
||||
pushState = (state = this.getState()): ClusterState => {
|
||||
logger.debug(`[CLUSTER]: push-state`, state);
|
||||
broadcastIpc({
|
||||
channel: "cluster:state",
|
||||
frameId: this.frameId,
|
||||
args: [state],
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
// get cluster system meta, e.g. use in "logger"
|
||||
getMeta() {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.contextName,
|
||||
initialized: this.initialized,
|
||||
online: this.online,
|
||||
accessible: this.accessible,
|
||||
disconnected: this.disconnected,
|
||||
}
|
||||
}
|
||||
|
||||
protected async getAllowedNamespaces() {
|
||||
const api = this.getProxyKubeconfig().makeApiClient(CoreV1Api)
|
||||
try {
|
||||
const namespaceList = await api.listNamespace()
|
||||
const nsAccessStatuses = await Promise.all(
|
||||
namespaceList.body.items.map(ns => this.canI({
|
||||
namespace: ns.metadata.name,
|
||||
resource: "pods",
|
||||
verb: "list",
|
||||
}))
|
||||
)
|
||||
return namespaceList.body.items
|
||||
.filter((ns, i) => nsAccessStatuses[i])
|
||||
.map(ns => ns.metadata.name)
|
||||
} catch (error) {
|
||||
const ctx = this.getProxyKubeconfig().getContextObject(this.contextName)
|
||||
if (ctx.namespace) return [ctx.namespace]
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
protected async getAllowedResources() {
|
||||
try {
|
||||
if (!this.allowedNamespaces.length) {
|
||||
return [];
|
||||
}
|
||||
const resourceAccessStatuses = await Promise.all(
|
||||
apiResources.map(apiResource => this.canI({
|
||||
resource: apiResource.resource,
|
||||
group: apiResource.group,
|
||||
verb: "list",
|
||||
namespace: this.allowedNamespaces[0]
|
||||
}))
|
||||
)
|
||||
return apiResources
|
||||
.filter((resource, i) => resourceAccessStatuses[i])
|
||||
.map(apiResource => apiResource.resource)
|
||||
} catch (error) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
-117
@@ -1,76 +1,42 @@
|
||||
import { CoreV1Api, KubeConfig } from "@kubernetes/client-node"
|
||||
import { ServerOptions } from "http-proxy"
|
||||
import * as url from "url"
|
||||
import type { PrometheusProvider, PrometheusService } from "./prometheus/provider-registry"
|
||||
import type { ClusterPreferences } from "../common/cluster-store";
|
||||
import type { Cluster } from "./cluster"
|
||||
import type httpProxy from "http-proxy"
|
||||
import url, { UrlWithStringQuery } from "url";
|
||||
import { CoreV1Api } from "@kubernetes/client-node"
|
||||
import { prometheusProviders } from "../common/prometheus-providers"
|
||||
import logger from "./logger"
|
||||
import { getFreePort } from "./port"
|
||||
import { KubeAuthProxy } from "./kube-auth-proxy"
|
||||
import { Cluster, ClusterPreferences } from "./cluster"
|
||||
import { prometheusProviders } from "../common/prometheus-providers"
|
||||
import { PrometheusService, PrometheusProvider } from "./prometheus/provider-registry"
|
||||
|
||||
export class ContextHandler {
|
||||
public contextName: string
|
||||
public id: string
|
||||
public url: string
|
||||
public clusterUrl: url.UrlWithStringQuery
|
||||
public proxyServer: KubeAuthProxy
|
||||
public proxyPort: number
|
||||
public certData: string
|
||||
public authCertData: string
|
||||
public cluster: Cluster
|
||||
|
||||
protected apiTarget: ServerOptions
|
||||
protected proxyTarget: ServerOptions
|
||||
protected clientCert: string
|
||||
protected clientKey: string
|
||||
protected secureApiConnection = true
|
||||
protected defaultNamespace: string
|
||||
protected kubernetesApi: string
|
||||
public proxyPort: number;
|
||||
public clusterUrl: UrlWithStringQuery;
|
||||
protected kubeAuthProxy: KubeAuthProxy
|
||||
protected apiTarget: httpProxy.ServerOptions
|
||||
protected prometheusProvider: string
|
||||
protected prometheusPath: string
|
||||
protected clusterName: string
|
||||
|
||||
constructor(kc: KubeConfig, cluster: Cluster) {
|
||||
this.id = cluster.id
|
||||
|
||||
this.cluster = cluster
|
||||
this.clusterUrl = url.parse(cluster.apiUrl)
|
||||
this.contextName = cluster.contextName;
|
||||
this.defaultNamespace = kc.getContextObject(cluster.contextName).namespace
|
||||
this.url = `http://${this.id}.localhost:${cluster.port}/`
|
||||
this.kubernetesApi = `http://127.0.0.1:${cluster.port}/${this.id}`
|
||||
|
||||
this.setClusterPreferences(cluster.preferences)
|
||||
constructor(protected cluster: Cluster) {
|
||||
this.clusterUrl = url.parse(cluster.apiUrl);
|
||||
this.setupPrometheus(cluster.preferences);
|
||||
}
|
||||
|
||||
public async init() {
|
||||
await this.resolveProxyPort()
|
||||
}
|
||||
|
||||
public setClusterPreferences(clusterPreferences?: ClusterPreferences) {
|
||||
this.prometheusProvider = clusterPreferences.prometheusProvider?.type
|
||||
|
||||
if (clusterPreferences && clusterPreferences.prometheus) {
|
||||
const prom = clusterPreferences.prometheus
|
||||
this.prometheusPath = `${prom.namespace}/services/${prom.service}:${prom.port}`
|
||||
}
|
||||
else {
|
||||
this.prometheusPath = null
|
||||
}
|
||||
if (clusterPreferences && clusterPreferences.clusterName) {
|
||||
this.clusterName = clusterPreferences.clusterName;
|
||||
}
|
||||
else {
|
||||
this.clusterName = this.contextName;
|
||||
protected setupPrometheus(preferences: ClusterPreferences = {}) {
|
||||
this.prometheusProvider = preferences.prometheusProvider?.type;
|
||||
this.prometheusPath = null;
|
||||
if (preferences.prometheus) {
|
||||
const { namespace, service, port } = preferences.prometheus
|
||||
this.prometheusPath = `${namespace}/services/${service}:${port}`
|
||||
}
|
||||
}
|
||||
|
||||
protected async resolvePrometheusPath(): Promise<string> {
|
||||
const {service, namespace, port} = await this.getPrometheusService()
|
||||
const { service, namespace, port } = await this.getPrometheusService()
|
||||
return `${namespace}/services/${service}:${port}`
|
||||
}
|
||||
|
||||
public async getPrometheusProvider() {
|
||||
async getPrometheusProvider() {
|
||||
if (!this.prometheusProvider) {
|
||||
const service = await this.getPrometheusService()
|
||||
logger.info(`using ${service.id} as prometheus provider`)
|
||||
@@ -79,36 +45,35 @@ export class ContextHandler {
|
||||
return prometheusProviders.find(p => p.id === this.prometheusProvider)
|
||||
}
|
||||
|
||||
public async getPrometheusService(): Promise<PrometheusService> {
|
||||
const providers = this.prometheusProvider ? prometheusProviders.filter((p, _) => p.id == this.prometheusProvider) : prometheusProviders
|
||||
async getPrometheusService(): Promise<PrometheusService> {
|
||||
const providers = this.prometheusProvider ? prometheusProviders.filter(provider => provider.id == this.prometheusProvider) : prometheusProviders;
|
||||
const prometheusPromises: Promise<PrometheusService>[] = providers.map(async (provider: PrometheusProvider): Promise<PrometheusService> => {
|
||||
const apiClient = this.cluster.proxyKubeconfig().makeApiClient(CoreV1Api)
|
||||
const apiClient = this.cluster.getProxyKubeconfig().makeApiClient(CoreV1Api)
|
||||
return await provider.getPrometheusService(apiClient)
|
||||
})
|
||||
const resolvedPrometheusServices = await Promise.all(prometheusPromises)
|
||||
const service = resolvedPrometheusServices.filter(n => n)[0]
|
||||
if (service) {
|
||||
return service
|
||||
}
|
||||
else {
|
||||
return {
|
||||
id: "lens",
|
||||
namespace: "lens-metrics",
|
||||
service: "prometheus",
|
||||
port: 80
|
||||
}
|
||||
const service = resolvedPrometheusServices.filter(n => n)[0];
|
||||
return service || {
|
||||
id: "lens",
|
||||
namespace: "lens-metrics",
|
||||
service: "prometheus",
|
||||
port: 80
|
||||
}
|
||||
}
|
||||
|
||||
public async getPrometheusPath(): Promise<string> {
|
||||
if (this.prometheusPath) return this.prometheusPath
|
||||
|
||||
this.prometheusPath = await this.resolvePrometheusPath()
|
||||
|
||||
return this.prometheusPath
|
||||
async getPrometheusPath(): Promise<string> {
|
||||
if (!this.prometheusPath) {
|
||||
this.prometheusPath = await this.resolvePrometheusPath()
|
||||
}
|
||||
return this.prometheusPath;
|
||||
}
|
||||
|
||||
public async getApiTarget(isWatchRequest = false): Promise<ServerOptions> {
|
||||
async resolveAuthProxyUrl() {
|
||||
const proxyPort = await this.ensurePort();
|
||||
return `http://127.0.0.1:${proxyPort}`;
|
||||
}
|
||||
|
||||
async getApiTarget(isWatchRequest = false): Promise<httpProxy.ServerOptions> {
|
||||
if (this.apiTarget && !isWatchRequest) {
|
||||
return this.apiTarget
|
||||
}
|
||||
@@ -120,65 +85,45 @@ export class ContextHandler {
|
||||
return apiTarget
|
||||
}
|
||||
|
||||
protected async newApiTarget(timeout: number): Promise<ServerOptions> {
|
||||
protected async newApiTarget(timeout: number): Promise<httpProxy.ServerOptions> {
|
||||
const proxyUrl = await this.resolveAuthProxyUrl();
|
||||
return {
|
||||
target: proxyUrl + this.clusterUrl.path,
|
||||
changeOrigin: true,
|
||||
timeout: timeout,
|
||||
headers: {
|
||||
"Host": this.clusterUrl.hostname
|
||||
},
|
||||
target: {
|
||||
port: await this.resolveProxyPort(),
|
||||
protocol: "http://",
|
||||
host: "localhost",
|
||||
path: this.clusterUrl.path
|
||||
"Host": this.clusterUrl.hostname,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
protected async resolveProxyPort(): Promise<number> {
|
||||
if (this.proxyPort) return this.proxyPort
|
||||
|
||||
let serverPort: number = null
|
||||
try {
|
||||
serverPort = await getFreePort()
|
||||
} catch (error) {
|
||||
logger.error(error)
|
||||
throw(error)
|
||||
async ensurePort(): Promise<number> {
|
||||
if (!this.proxyPort) {
|
||||
this.proxyPort = await getFreePort();
|
||||
}
|
||||
this.proxyPort = serverPort
|
||||
|
||||
return serverPort
|
||||
return this.proxyPort
|
||||
}
|
||||
|
||||
public async withTemporaryKubeconfig(callback: (kubeconfig: string) => Promise<any>) {
|
||||
try {
|
||||
await callback(this.cluster.proxyKubeconfigPath())
|
||||
} catch (error) {
|
||||
throw(error)
|
||||
}
|
||||
}
|
||||
|
||||
public async ensureServer() {
|
||||
if (!this.proxyServer) {
|
||||
const proxyPort = await this.resolveProxyPort()
|
||||
async ensureServer() {
|
||||
if (!this.kubeAuthProxy) {
|
||||
await this.ensurePort();
|
||||
const proxyEnv = Object.assign({}, process.env)
|
||||
if (this.cluster?.preferences.httpsProxy) {
|
||||
if (this.cluster.preferences.httpsProxy) {
|
||||
proxyEnv.HTTPS_PROXY = this.cluster.preferences.httpsProxy
|
||||
}
|
||||
this.proxyServer = new KubeAuthProxy(this.cluster, proxyPort, proxyEnv)
|
||||
await this.proxyServer.run()
|
||||
this.kubeAuthProxy = new KubeAuthProxy(this.cluster, this.proxyPort, proxyEnv)
|
||||
await this.kubeAuthProxy.run()
|
||||
}
|
||||
}
|
||||
|
||||
public stopServer() {
|
||||
if (this.proxyServer) {
|
||||
this.proxyServer.exit()
|
||||
this.proxyServer = null
|
||||
stopServer() {
|
||||
if (this.kubeAuthProxy) {
|
||||
this.kubeAuthProxy.exit()
|
||||
this.kubeAuthProxy = null
|
||||
}
|
||||
}
|
||||
|
||||
public proxyServerError() {
|
||||
return this.proxyServer?.lastError || ""
|
||||
get proxyLastError(): string {
|
||||
return this.kubeAuthProxy?.lastError || ""
|
||||
}
|
||||
}
|
||||
+24
-35
@@ -1,55 +1,44 @@
|
||||
import { KubeConfig } from "@kubernetes/client-node"
|
||||
import logger from "./logger";
|
||||
import { Cluster } from "./cluster";
|
||||
import { Feature, FeatureStatusMap } from "./feature"
|
||||
import { Feature, FeatureStatusMap, FeatureMap } from "./feature"
|
||||
import { MetricsFeature } from "../features/metrics"
|
||||
import { UserModeFeature } from "../features/user-mode"
|
||||
|
||||
const ALL_FEATURES: any = {
|
||||
'metrics': new MetricsFeature(null),
|
||||
'user-mode': new UserModeFeature(null),
|
||||
}
|
||||
const ALL_FEATURES: Map<string, Feature> = new Map([
|
||||
[MetricsFeature.id, new MetricsFeature(null)],
|
||||
[UserModeFeature.id, new UserModeFeature(null)],
|
||||
]);
|
||||
|
||||
export async function getFeatures(cluster: Cluster): Promise<FeatureStatusMap> {
|
||||
return new Promise<FeatureStatusMap>(async (resolve, reject) => {
|
||||
const result: FeatureStatusMap = {};
|
||||
logger.debug(`features for ${cluster.contextName}`);
|
||||
for (const key in ALL_FEATURES) {
|
||||
logger.debug(`feature ${key}`);
|
||||
if (ALL_FEATURES.hasOwnProperty(key)) {
|
||||
logger.debug("getting feature status...");
|
||||
const feature = ALL_FEATURES[key] as Feature;
|
||||
const kc = new KubeConfig()
|
||||
kc.loadFromFile(cluster.proxyKubeconfigPath())
|
||||
|
||||
const status = await feature.featureStatus(kc);
|
||||
result[feature.name] = status
|
||||
const result: FeatureStatusMap = {};
|
||||
logger.debug(`features for ${cluster.contextName}`);
|
||||
|
||||
} else {
|
||||
logger.error("ALL_FEATURES.hasOwnProperty(key) returned FALSE ?!?!?!?!")
|
||||
for (const [key, feature] of ALL_FEATURES) {
|
||||
logger.debug(`feature ${key}`);
|
||||
logger.debug("getting feature status...");
|
||||
|
||||
}
|
||||
}
|
||||
logger.debug(`getFeatures resolving with features: ${JSON.stringify(result)}`);
|
||||
resolve(result);
|
||||
});
|
||||
const kc = new KubeConfig();
|
||||
kc.loadFromFile(cluster.getProxyKubeconfigPath());
|
||||
|
||||
result[feature.name] = await feature.featureStatus(kc);
|
||||
}
|
||||
|
||||
logger.debug(`getFeatures resolving with features: ${JSON.stringify(result)}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
export async function installFeature(name: string, cluster: Cluster, config: any) {
|
||||
const feature = ALL_FEATURES[name] as Feature
|
||||
export async function installFeature(name: string, cluster: Cluster, config: any): Promise<void> {
|
||||
// TODO Figure out how to handle config stuff
|
||||
await feature.install(cluster)
|
||||
return ALL_FEATURES.get(name).install(cluster)
|
||||
}
|
||||
|
||||
export async function upgradeFeature(name: string, cluster: Cluster, config: any) {
|
||||
const feature = ALL_FEATURES[name] as Feature
|
||||
export async function upgradeFeature(name: string, cluster: Cluster, config: any): Promise<void> {
|
||||
// TODO Figure out how to handle config stuff
|
||||
await feature.upgrade(cluster)
|
||||
return ALL_FEATURES.get(name).upgrade(cluster)
|
||||
}
|
||||
|
||||
export async function uninstallFeature(name: string, cluster: Cluster) {
|
||||
const feature = ALL_FEATURES[name] as Feature
|
||||
|
||||
await feature.uninstall(cluster)
|
||||
export async function uninstallFeature(name: string, cluster: Cluster): Promise<void> {
|
||||
return ALL_FEATURES.get(name).uninstall(cluster)
|
||||
}
|
||||
+41
-54
@@ -1,96 +1,83 @@
|
||||
import fs from "fs";
|
||||
import path from "path"
|
||||
import * as hb from "handlebars"
|
||||
import hb from "handlebars"
|
||||
import { ResourceApplier } from "./resource-applier"
|
||||
import { KubeConfig, CoreV1Api, Watch } from "@kubernetes/client-node"
|
||||
import logger from "./logger";
|
||||
import { CoreV1Api, KubeConfig, Watch } from "@kubernetes/client-node"
|
||||
import { Cluster } from "./cluster";
|
||||
import logger from "./logger";
|
||||
|
||||
export type FeatureStatus = {
|
||||
export type FeatureStatusMap = Record<string, FeatureStatus>
|
||||
export type FeatureMap = Record<string, Feature>
|
||||
|
||||
export interface FeatureInstallRequest {
|
||||
clusterId: string;
|
||||
name: string;
|
||||
config?: any;
|
||||
}
|
||||
|
||||
export interface FeatureStatus {
|
||||
currentVersion: string;
|
||||
installed: boolean;
|
||||
latestVersion: string;
|
||||
canUpgrade: boolean;
|
||||
// TODO We need bunch of other stuff too: upgradeable, latestVersion, ...
|
||||
};
|
||||
|
||||
export type FeatureStatusMap = {
|
||||
[name: string]: FeatureStatus;
|
||||
}
|
||||
|
||||
export abstract class Feature {
|
||||
name: string;
|
||||
config: any;
|
||||
latestVersion: string;
|
||||
|
||||
constructor(config: any) {
|
||||
if(config) {
|
||||
this.config = config;
|
||||
}
|
||||
}
|
||||
abstract async upgrade(cluster: Cluster): Promise<void>;
|
||||
|
||||
// TODO Return types for these?
|
||||
async install(cluster: Cluster): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve, reject) => {
|
||||
// Read and process yamls through handlebar
|
||||
const resources = this.renderTemplates();
|
||||
|
||||
// Apply processed manifests
|
||||
cluster.contextHandler.withTemporaryKubeconfig(async (kubeconfigPath) => {
|
||||
const resourceApplier = new ResourceApplier(cluster, kubeconfigPath)
|
||||
try {
|
||||
await resourceApplier.kubectlApplyAll(resources)
|
||||
resolve(true)
|
||||
} catch(error) {
|
||||
reject(error)
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
abstract async upgrade(cluster: Cluster): Promise<boolean>;
|
||||
|
||||
abstract async uninstall(cluster: Cluster): Promise<boolean>;
|
||||
abstract async uninstall(cluster: Cluster): Promise<void>;
|
||||
|
||||
abstract async featureStatus(kc: KubeConfig): Promise<FeatureStatus>;
|
||||
|
||||
constructor(public config: any) {
|
||||
}
|
||||
|
||||
async install(cluster: Cluster): Promise<void> {
|
||||
const resources = this.renderTemplates();
|
||||
try {
|
||||
await new ResourceApplier(cluster).kubectlApplyAll(resources);
|
||||
} catch (err) {
|
||||
logger.error("Installing feature error", { err, cluster });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
protected async deleteNamespace(kc: KubeConfig, name: string) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const client = kc.makeApiClient(CoreV1Api)
|
||||
const result = await client.deleteNamespace("lens-metrics", 'false', undefined, undefined, undefined, "Foreground");
|
||||
const nsVersion = result.body.metadata.resourceVersion;
|
||||
const nsWatch = new Watch(kc);
|
||||
const req = await nsWatch.watch('/api/v1/namespaces', {resourceVersion: nsVersion, fieldSelector: "metadata.name=lens-metrics"},
|
||||
(type, obj) => {
|
||||
if(type === 'DELETED') {
|
||||
const query: Record<string, string> = {
|
||||
resourceVersion: nsVersion,
|
||||
fieldSelector: "metadata.name=lens-metrics",
|
||||
}
|
||||
const req = await nsWatch.watch('/api/v1/namespaces', query,
|
||||
(phase, obj) => {
|
||||
if (phase === 'DELETED') {
|
||||
logger.debug(`namespace ${name} finally gone`)
|
||||
req.abort();
|
||||
resolve()
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
if(err) {
|
||||
reject(err)
|
||||
}
|
||||
(err?: any) => {
|
||||
if (err) reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected renderTemplates(): string[] {
|
||||
console.log("starting to render resources...");
|
||||
const resources: string[] = [];
|
||||
fs.readdirSync(this.manifestPath()).forEach((f) => {
|
||||
const file = path.join(this.manifestPath(), f);
|
||||
console.log("processing file:", file)
|
||||
fs.readdirSync(this.manifestPath()).forEach(filename => {
|
||||
const file = path.join(this.manifestPath(), filename);
|
||||
const raw = fs.readFileSync(file);
|
||||
console.log("raw file loaded");
|
||||
if(f.endsWith('.hb')) {
|
||||
console.log("processing HB template");
|
||||
if (filename.endsWith('.hb')) {
|
||||
const template = hb.compile(raw.toString());
|
||||
resources.push(template(this.config));
|
||||
console.log("HB template done");
|
||||
} else {
|
||||
console.log("using as raw, no HB detected");
|
||||
resources.push(raw.toString());
|
||||
}
|
||||
});
|
||||
@@ -100,7 +87,7 @@ export abstract class Feature {
|
||||
|
||||
protected manifestPath() {
|
||||
const devPath = path.join(__dirname, "..", 'src/features', this.name);
|
||||
if(fs.existsSync(devPath)) {
|
||||
if (fs.existsSync(devPath)) {
|
||||
return devPath;
|
||||
}
|
||||
return path.join(__dirname, "..", 'features', this.name);
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import fs from "fs"
|
||||
|
||||
export function ensureDir(dirname: string) {
|
||||
if (!fs.existsSync(dirname)) {
|
||||
fs.mkdirSync(dirname)
|
||||
}
|
||||
}
|
||||
|
||||
export function randomFileName(name: string) {
|
||||
return `${Math.random().toString(36).substring(2, 15)}-${Math.random().toString(36).substring(2, 15)}-${name}`
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import fs from "fs";
|
||||
import logger from "./logger";
|
||||
import * as yaml from "js-yaml";
|
||||
import { promiseExec } from "./promise-exec";
|
||||
import { helmCli } from "./helm-cli";
|
||||
|
||||
type HelmEnv = {
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
|
||||
export type HelmRepo = {
|
||||
name: string;
|
||||
url: string;
|
||||
cacheFilePath?: string;
|
||||
}
|
||||
|
||||
export class HelmRepoManager {
|
||||
private static instance: HelmRepoManager;
|
||||
public static cache = {}
|
||||
protected helmEnv: HelmEnv
|
||||
protected initialized: boolean
|
||||
|
||||
static getInstance(): HelmRepoManager {
|
||||
if(!HelmRepoManager.instance) {
|
||||
HelmRepoManager.instance = new HelmRepoManager()
|
||||
}
|
||||
return HelmRepoManager.instance;
|
||||
}
|
||||
|
||||
private constructor() {
|
||||
// use singleton getInstance()
|
||||
}
|
||||
|
||||
public async init() {
|
||||
const helm = await helmCli.binaryPath()
|
||||
if (!this.initialized) {
|
||||
this.helmEnv = await this.parseHelmEnv()
|
||||
await this.update()
|
||||
this.initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
protected 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 env: HelmEnv = {}
|
||||
lines.forEach((line: string) => {
|
||||
const [key, value] = line.split("=")
|
||||
if (key && value) {
|
||||
env[key] = value.replace(/"/g, "") // strip quotas
|
||||
}
|
||||
})
|
||||
return env
|
||||
}
|
||||
|
||||
public async repositories(): Promise<Array<HelmRepo>> {
|
||||
if(!this.initialized) {
|
||||
await this.init()
|
||||
}
|
||||
const repositoryFilePath = this.helmEnv.HELM_REPOSITORY_CONFIG
|
||||
const repoFile = await fs.promises.readFile(repositoryFilePath, 'utf8').catch(async (error) => {
|
||||
return null
|
||||
})
|
||||
if(!repoFile) {
|
||||
await this.addRepo({ name: "stable", url: "https://kubernetes-charts.storage.googleapis.com/" })
|
||||
return await this.repositories()
|
||||
}
|
||||
try {
|
||||
const repositories = yaml.safeLoad(repoFile)
|
||||
const result = repositories['repositories'].map((repository: HelmRepo) => {
|
||||
return {
|
||||
name: repository.name,
|
||||
url: repository.url,
|
||||
cacheFilePath: `${this.helmEnv.HELM_REPOSITORY_CACHE}/${repository['name']}-index.yaml`
|
||||
}
|
||||
});
|
||||
if (result.length == 0) {
|
||||
await this.addRepo({ name: "stable", url: "https://kubernetes-charts.storage.googleapis.com/" })
|
||||
return await this.repositories()
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
logger.debug(error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
public async repository(name: string) {
|
||||
const repositories = await this.repositories()
|
||||
return repositories.find((repo: HelmRepo) => {
|
||||
return repo.name == name
|
||||
})
|
||||
}
|
||||
|
||||
public async update() {
|
||||
const helm = await helmCli.binaryPath()
|
||||
logger.debug(`${helm} repo update`)
|
||||
|
||||
const {stdout } = await promiseExec(`"${helm}" repo update`).catch((error) => { return { stdout: error.stdout } })
|
||||
return stdout
|
||||
}
|
||||
|
||||
protected async addRepositories(repositories: HelmRepo[]){
|
||||
const currentRepositories = await this.repositories()
|
||||
repositories.forEach(async (repo: HelmRepo) => {
|
||||
try {
|
||||
const repoExists = currentRepositories.find((currentRepo: HelmRepo) => {
|
||||
return currentRepo.url == repo.url
|
||||
})
|
||||
if(!repoExists) {
|
||||
await this.addRepo(repo)
|
||||
}
|
||||
}
|
||||
catch(error) {
|
||||
logger.error(JSON.stringify(error))
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected async pruneRepositories(repositoriesToKeep: HelmRepo[]) {
|
||||
const repositories = await this.repositories()
|
||||
repositories.filter((repo: HelmRepo) => {
|
||||
return repositoriesToKeep.find((repoToKeep: HelmRepo) => {
|
||||
return repo.name == repoToKeep.name
|
||||
}) === undefined
|
||||
}).forEach(async (repo: HelmRepo) => {
|
||||
try {
|
||||
const output = await this.removeRepo(repo)
|
||||
logger.debug(output)
|
||||
} catch(error) {
|
||||
logger.error(error)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
public async addRepo(repository: HelmRepo) {
|
||||
const helm = await helmCli.binaryPath()
|
||||
logger.debug(`${helm} repo add ${repository.name} ${repository.url}`)
|
||||
|
||||
const {stdout } = await promiseExec(`"${helm}" repo add ${repository.name} ${repository.url}`).catch((error) => { throw(error.stderr)})
|
||||
return stdout
|
||||
}
|
||||
|
||||
public async removeRepo(repository: HelmRepo): Promise<string> {
|
||||
const helm = await helmCli.binaryPath()
|
||||
logger.debug(`${helm} repo remove ${repository.name} ${repository.url}`)
|
||||
|
||||
const { stdout, stderr } = await promiseExec(`"${helm}" repo remove ${repository.name}`).catch((error) => { throw(error.stderr)})
|
||||
return stdout
|
||||
}
|
||||
}
|
||||
|
||||
export const repoManager = HelmRepoManager.getInstance()
|
||||
@@ -1,17 +1,18 @@
|
||||
import fs from "fs";
|
||||
import * as yaml from "js-yaml";
|
||||
import { HelmRepo, HelmRepoManager } from "./helm-repo-manager"
|
||||
import logger from "./logger";
|
||||
import { promiseExec } from "./promise-exec"
|
||||
import logger from "../logger";
|
||||
import { promiseExec } from "../promise-exec"
|
||||
import { helmCli } from "./helm-cli"
|
||||
|
||||
type CachedYaml = {
|
||||
entries: any;
|
||||
entries: any; // todo: types
|
||||
}
|
||||
|
||||
export class HelmChartManager {
|
||||
protected cache: any
|
||||
protected cache: any = {}
|
||||
protected repo: HelmRepo
|
||||
|
||||
constructor(repo: HelmRepo){
|
||||
this.cache = HelmRepoManager.cache
|
||||
this.repo = repo
|
||||
@@ -1,7 +1,7 @@
|
||||
import packageInfo from "../../package.json"
|
||||
import packageInfo from "../../../package.json"
|
||||
import path from "path"
|
||||
import { LensBinary, LensBinaryOpts } from "./lens-binary"
|
||||
import { isProduction } from "../common/vars";
|
||||
import { LensBinary, LensBinaryOpts } from "../lens-binary"
|
||||
import { isProduction } from "../../common/vars";
|
||||
|
||||
export class HelmCli extends LensBinary {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import * as tempy from "tempy";
|
||||
import fs from "fs";
|
||||
import * as yaml from "js-yaml";
|
||||
import { promiseExec} from "./promise-exec"
|
||||
import { promiseExec} from "../promise-exec"
|
||||
import { helmCli } from "./helm-cli";
|
||||
import { Cluster } from "./cluster";
|
||||
import { toCamelCase } from "../common/utils/camelCase";
|
||||
import { Cluster } from "../cluster";
|
||||
import { toCamelCase } from "../../common/utils/camelCase";
|
||||
|
||||
export class HelmReleaseManager {
|
||||
|
||||
@@ -54,7 +54,7 @@ export class HelmReleaseManager {
|
||||
await fs.promises.writeFile(fileName, yaml.safeDump(values))
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await promiseExec(`"${helm}" upgrade ${name} ${chart} --version ${version} -f ${fileName} --namespace ${namespace} --kubeconfig ${cluster.proxyKubeconfigPath()}`).catch((error) => { throw(error.stderr)})
|
||||
const { stdout, stderr } = await promiseExec(`"${helm}" upgrade ${name} ${chart} --version ${version} -f ${fileName} --namespace ${namespace} --kubeconfig ${cluster.getProxyKubeconfigPath()}`).catch((error) => { throw(error.stderr)})
|
||||
return {
|
||||
log: stdout,
|
||||
release: this.getRelease(name, namespace, cluster)
|
||||
@@ -66,7 +66,7 @@ export class HelmReleaseManager {
|
||||
|
||||
public async getRelease(name: string, namespace: string, cluster: Cluster) {
|
||||
const helm = await helmCli.binaryPath()
|
||||
const {stdout, stderr} = await promiseExec(`"${helm}" status ${name} --output json --namespace ${namespace} --kubeconfig ${cluster.proxyKubeconfigPath()}`).catch((error) => { throw(error.stderr)})
|
||||
const {stdout, stderr} = await promiseExec(`"${helm}" status ${name} --output json --namespace ${namespace} --kubeconfig ${cluster.getProxyKubeconfigPath()}`).catch((error) => { throw(error.stderr)})
|
||||
const release = JSON.parse(stdout)
|
||||
release.resources = await this.getResources(name, namespace, cluster)
|
||||
return release
|
||||
@@ -99,8 +99,8 @@ export class HelmReleaseManager {
|
||||
|
||||
protected async getResources(name: string, namespace: string, cluster: Cluster) {
|
||||
const helm = await helmCli.binaryPath()
|
||||
const kubectl = await cluster.kubeCtl.kubectlPath()
|
||||
const pathToKubeconfig = cluster.proxyKubeconfigPath()
|
||||
const kubectl = await cluster.kubeCtl.getPath()
|
||||
const pathToKubeconfig = cluster.getProxyKubeconfigPath()
|
||||
const { stdout } = await promiseExec(`"${helm}" get manifest ${name} --namespace ${namespace} --kubeconfig ${pathToKubeconfig} | "${kubectl}" get -n ${namespace} --kubeconfig ${pathToKubeconfig} -f - -o=json`).catch((error) => {
|
||||
return { stdout: JSON.stringify({items: []})}
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
import yaml from "js-yaml";
|
||||
import { readFile } from "fs-extra";
|
||||
import { promiseExec } from "../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";
|
||||
|
||||
export type HelmEnv = Record<string, string> & {
|
||||
HELM_REPOSITORY_CACHE?: string;
|
||||
HELM_REPOSITORY_CONFIG?: string;
|
||||
}
|
||||
|
||||
export interface HelmRepoConfig {
|
||||
repositories: HelmRepo[]
|
||||
}
|
||||
|
||||
export interface HelmRepo {
|
||||
name: string;
|
||||
url: string;
|
||||
cacheFilePath?: string
|
||||
caFile?: string,
|
||||
certFile?: string,
|
||||
insecure_skip_tls_verify?: boolean,
|
||||
keyFile?: string,
|
||||
username?: string,
|
||||
password?: string,
|
||||
}
|
||||
|
||||
export class HelmRepoManager extends Singleton {
|
||||
static cache = {} // todo: remove implicit updates in helm-chart-manager.ts
|
||||
|
||||
protected repos: HelmRepo[];
|
||||
protected helmEnv: HelmEnv
|
||||
protected initialized: boolean
|
||||
|
||||
async loadAvailableRepos(): Promise<HelmRepo[]> {
|
||||
const res = await customRequestPromise({
|
||||
uri: "https://hub.helm.sh/assets/js/repos.json",
|
||||
json: true,
|
||||
resolveWithFullResponse: true,
|
||||
timeout: 10000,
|
||||
});
|
||||
return orderBy<HelmRepo>(res.body.data, repo => repo.name);
|
||||
}
|
||||
|
||||
async init() {
|
||||
await helmCli.ensureBinary();
|
||||
if (!this.initialized) {
|
||||
this.helmEnv = await this.parseHelmEnv()
|
||||
await this.update()
|
||||
this.initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
protected 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 env: HelmEnv = {}
|
||||
lines.forEach((line: string) => {
|
||||
const [key, value] = line.split("=")
|
||||
if (key && value) {
|
||||
env[key] = value.replace(/"/g, "") // strip quotas
|
||||
}
|
||||
})
|
||||
return env
|
||||
}
|
||||
|
||||
public async repositories(): Promise<HelmRepo[]> {
|
||||
if (!this.initialized) {
|
||||
await this.init()
|
||||
}
|
||||
try {
|
||||
const repoConfigFile = this.helmEnv.HELM_REPOSITORY_CONFIG;
|
||||
const { repositories }: HelmRepoConfig = await readFile(repoConfigFile, 'utf8')
|
||||
.then((yamlContent: string) => yaml.safeLoad(yamlContent))
|
||||
.catch(() => ({
|
||||
repositories: []
|
||||
}));
|
||||
if (!repositories.length) {
|
||||
await this.addRepo({ name: "stable", url: "https://kubernetes-charts.storage.googleapis.com/" });
|
||||
return await this.repositories();
|
||||
}
|
||||
return repositories.map(repo => ({
|
||||
...repo,
|
||||
cacheFilePath: `${this.helmEnv.HELM_REPOSITORY_CACHE}/${repo.name}-index.yaml`
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error(`[HELM]: repositories listing error "${error}"`)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
public async repository(name: string) {
|
||||
const repositories = await this.repositories()
|
||||
return repositories.find(repo => repo.name == name);
|
||||
}
|
||||
|
||||
public async update() {
|
||||
const helm = await helmCli.binaryPath()
|
||||
const { stdout } = await promiseExec(`"${helm}" repo update`).catch((error) => {
|
||||
return { stdout: error.stdout }
|
||||
})
|
||||
return stdout
|
||||
}
|
||||
|
||||
public 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
|
||||
}
|
||||
|
||||
public async removeRepo({ name, url }: HelmRepo): Promise<string> {
|
||||
logger.info(`[HELM]: removing repo "${name}" from ${url}`);
|
||||
const helm = await helmCli.binaryPath()
|
||||
const { stdout, stderr } = await promiseExec(`"${helm}" repo remove ${name}`).catch((error) => {
|
||||
throw(error.stderr)
|
||||
})
|
||||
return stdout
|
||||
}
|
||||
}
|
||||
|
||||
export const repoManager = HelmRepoManager.getInstance<HelmRepoManager>()
|
||||
@@ -1,13 +1,12 @@
|
||||
import { Cluster } from "./cluster";
|
||||
import logger from "./logger";
|
||||
import { Cluster } from "../cluster";
|
||||
import logger from "../logger";
|
||||
import { repoManager } from "./helm-repo-manager";
|
||||
import { HelmChartManager } from "./helm-chart-manager";
|
||||
import { releaseManager } from "./helm-release-manager";
|
||||
|
||||
class HelmService {
|
||||
public async installChart(cluster: Cluster, data: {chart: string; values: {}; name: string; namespace: string; version: string}) {
|
||||
const installResult = await releaseManager.installChart(data.chart, data.values, data.name, data.namespace, data.version, cluster.proxyKubeconfigPath())
|
||||
return installResult
|
||||
public async installChart(cluster: Cluster, data: { chart: string; values: {}; name: string; namespace: string; version: string }) {
|
||||
return await releaseManager.installChart(data.chart, data.values, data.name, data.namespace, data.version, cluster.getProxyKubeconfigPath())
|
||||
}
|
||||
|
||||
public async listCharts() {
|
||||
@@ -19,7 +18,7 @@ class HelmService {
|
||||
const manager = new HelmChartManager(repo)
|
||||
let entries = await manager.charts()
|
||||
entries = this.excludeDeprecated(entries)
|
||||
for(const key in entries) {
|
||||
for (const key in entries) {
|
||||
entries[key] = entries[key][0]
|
||||
}
|
||||
charts[repo.name] = entries
|
||||
@@ -48,50 +47,44 @@ class HelmService {
|
||||
|
||||
public async listReleases(cluster: Cluster, namespace: string = null) {
|
||||
await repoManager.init()
|
||||
const releases = await releaseManager.listReleases(cluster.proxyKubeconfigPath(), namespace)
|
||||
return releases
|
||||
return await releaseManager.listReleases(cluster.getProxyKubeconfigPath(), namespace)
|
||||
}
|
||||
|
||||
public async getRelease(cluster: Cluster, releaseName: string, namespace: string) {
|
||||
public async getRelease(cluster: Cluster, releaseName: string, namespace: string) {
|
||||
logger.debug("Fetch release")
|
||||
const release = await releaseManager.getRelease(releaseName, namespace, cluster)
|
||||
return release
|
||||
return await releaseManager.getRelease(releaseName, namespace, cluster)
|
||||
}
|
||||
|
||||
public async getReleaseValues(cluster: Cluster, releaseName: string, namespace: string) {
|
||||
logger.debug("Fetch release values")
|
||||
const values = await releaseManager.getValues(releaseName, namespace, cluster.proxyKubeconfigPath())
|
||||
return values
|
||||
return await releaseManager.getValues(releaseName, namespace, cluster.getProxyKubeconfigPath())
|
||||
}
|
||||
|
||||
public async getReleaseHistory(cluster: Cluster, releaseName: string, namespace: string) {
|
||||
logger.debug("Fetch release history")
|
||||
const history = await releaseManager.getHistory(releaseName, namespace, cluster.proxyKubeconfigPath())
|
||||
return(history)
|
||||
return await releaseManager.getHistory(releaseName, namespace, cluster.getProxyKubeconfigPath())
|
||||
}
|
||||
|
||||
public async deleteRelease(cluster: Cluster, releaseName: string, namespace: string) {
|
||||
logger.debug("Delete release")
|
||||
const release = await releaseManager.deleteRelease(releaseName, namespace, cluster.proxyKubeconfigPath())
|
||||
return release
|
||||
return await releaseManager.deleteRelease(releaseName, namespace, cluster.getProxyKubeconfigPath())
|
||||
}
|
||||
|
||||
public async updateRelease(cluster: Cluster, releaseName: string, namespace: string, data: {chart: string; values: {}; version: string}) {
|
||||
public async updateRelease(cluster: Cluster, releaseName: string, namespace: string, data: { chart: string; values: {}; version: string }) {
|
||||
logger.debug("Upgrade release")
|
||||
const release = await releaseManager.upgradeRelease(releaseName, data.chart, data.values, namespace, data.version, cluster)
|
||||
return release
|
||||
return await releaseManager.upgradeRelease(releaseName, data.chart, data.values, namespace, data.version, cluster)
|
||||
}
|
||||
|
||||
public async rollback(cluster: Cluster, releaseName: string, namespace: string, revision: number) {
|
||||
logger.debug("Rollback release")
|
||||
const output = await releaseManager.rollback(releaseName, namespace, revision, cluster.proxyKubeconfigPath())
|
||||
return({ message: output })
|
||||
const output = await releaseManager.rollback(releaseName, namespace, revision, cluster.getProxyKubeconfigPath())
|
||||
return { message: output }
|
||||
}
|
||||
|
||||
protected excludeDeprecated(entries: any) {
|
||||
for(const key in entries) {
|
||||
for (const key in entries) {
|
||||
entries[key] = entries[key].filter((entry: any) => {
|
||||
if(Array.isArray(entry)) {
|
||||
if (Array.isArray(entry)) {
|
||||
return entry[0]['deprecated'] != true
|
||||
}
|
||||
return entry["deprecated"] != true
|
||||
+43
-98
@@ -1,141 +1,86 @@
|
||||
// Main process
|
||||
|
||||
import "../common/system-ca"
|
||||
import { app, dialog, protocol } from "electron"
|
||||
import { isMac, vueAppName, isDevelopment } from "../common/vars";
|
||||
if (isDevelopment) {
|
||||
const appName = 'LensDev';
|
||||
app.setName(appName);
|
||||
const appData = app.getPath('appData');
|
||||
app.setPath('userData', path.join(appData, appName));
|
||||
}
|
||||
import "../common/prometheus-providers"
|
||||
import { PromiseIpc } from "electron-promise-ipc"
|
||||
import { app, dialog } from "electron"
|
||||
import { appName } from "../common/vars";
|
||||
import path from "path"
|
||||
import { format as formatUrl } from "url"
|
||||
import logger from "./logger"
|
||||
import initMenu from "./menu"
|
||||
import * as proxy from "./proxy"
|
||||
import { LensProxy } from "./lens-proxy"
|
||||
import { WindowManager } from "./window-manager";
|
||||
import { clusterStore } from "../common/cluster-store"
|
||||
import { tracker } from "./tracker"
|
||||
import { ClusterManager } from "./cluster-manager";
|
||||
import AppUpdater from "./app-updater"
|
||||
import { shellSync } from "./shell-sync"
|
||||
import { getFreePort } from "./port"
|
||||
import { mangleProxyEnv } from "./proxy-env"
|
||||
import { findMainWebContents } from "./webcontents"
|
||||
import { registerStaticProtocol } from "../common/register-static";
|
||||
import { registerFileProtocol } from "../common/register-protocol";
|
||||
import { clusterStore } from "../common/cluster-store"
|
||||
import { userStore } from "../common/user-store";
|
||||
import { workspaceStore } from "../common/workspace-store";
|
||||
import { tracker } from "../common/tracker";
|
||||
import logger from "./logger"
|
||||
|
||||
const workingDir = path.join(app.getPath("appData"), appName);
|
||||
app.setName(appName);
|
||||
if(!process.env.CICD) {
|
||||
app.setPath("userData", workingDir);
|
||||
}
|
||||
|
||||
let windowManager: WindowManager;
|
||||
let clusterManager: ClusterManager;
|
||||
let proxyServer: LensProxy;
|
||||
|
||||
mangleProxyEnv()
|
||||
if (app.commandLine.getSwitchValue("proxy-server") !== "") {
|
||||
process.env.HTTPS_PROXY = app.commandLine.getSwitchValue("proxy-server")
|
||||
}
|
||||
|
||||
const promiseIpc = new PromiseIpc({ timeout: 2000 })
|
||||
let windowManager: WindowManager = null;
|
||||
let clusterManager: ClusterManager = null;
|
||||
let proxyServer: proxy.LensProxy = null;
|
||||
|
||||
const vmURL = formatUrl({
|
||||
pathname: path.join(__dirname, `${vueAppName}.html`),
|
||||
protocol: "file",
|
||||
slashes: true,
|
||||
})
|
||||
|
||||
async function main() {
|
||||
shellSync(app.getLocale())
|
||||
await shellSync();
|
||||
logger.info(`🚀 Starting Lens from "${workingDir}"`)
|
||||
|
||||
tracker.event("app", "start");
|
||||
const updater = new AppUpdater()
|
||||
updater.start();
|
||||
|
||||
tracker.event("app", "start");
|
||||
registerFileProtocol("static", __static);
|
||||
|
||||
registerStaticProtocol();
|
||||
protocol.registerFileProtocol('store', (request, callback) => {
|
||||
const url = request.url.substr(8)
|
||||
callback(path.normalize(`${app.getPath("userData")}/${url}`))
|
||||
}, (error) => {
|
||||
if (error) console.error('Failed to register protocol')
|
||||
})
|
||||
|
||||
let port: number = null
|
||||
// find free port
|
||||
let proxyPort: number
|
||||
try {
|
||||
port = await getFreePort()
|
||||
proxyPort = await getFreePort()
|
||||
} catch (error) {
|
||||
logger.error(error)
|
||||
await dialog.showErrorBox("Lens Error", "Could not find a free port for the cluster proxy")
|
||||
app.quit();
|
||||
}
|
||||
|
||||
// preload configuration from stores
|
||||
await Promise.all([
|
||||
userStore.load(),
|
||||
clusterStore.load(),
|
||||
workspaceStore.load(),
|
||||
]);
|
||||
|
||||
// create cluster manager
|
||||
clusterManager = new ClusterManager(clusterStore.getAllClusterObjects(), port)
|
||||
clusterManager = new ClusterManager(proxyPort);
|
||||
|
||||
// run proxy
|
||||
try {
|
||||
proxyServer = proxy.listen(port, clusterManager)
|
||||
proxyServer = LensProxy.create(proxyPort, clusterManager);
|
||||
} catch (error) {
|
||||
logger.error(`Could not start proxy (127.0.0:${port}): ${error.message}`)
|
||||
await dialog.showErrorBox("Lens Error", `Could not start proxy (127.0.0:${port}): ${error.message || "unknown error"}`)
|
||||
logger.error(`Could not start proxy (127.0.0:${proxyPort}): ${error.message}`)
|
||||
await dialog.showErrorBox("Lens Error", `Could not start proxy (127.0.0:${proxyPort}): ${error.message || "unknown error"}`)
|
||||
app.quit();
|
||||
}
|
||||
|
||||
// boot windowmanager
|
||||
windowManager = new WindowManager();
|
||||
windowManager.showMain(vmURL)
|
||||
|
||||
initMenu({
|
||||
logoutHook: async () => {
|
||||
// IPC send needs webContents as we're sending it to renderer
|
||||
promiseIpc.send('logout', findMainWebContents(), {}).then((data: any) => {
|
||||
logger.debug("logout IPC sent");
|
||||
})
|
||||
},
|
||||
showPreferencesHook: async () => {
|
||||
// IPC send needs webContents as we're sending it to renderer
|
||||
promiseIpc.send('navigate', findMainWebContents(), { name: 'preferences-page' }).then((data: any) => {
|
||||
logger.debug("navigate: preferences IPC sent");
|
||||
})
|
||||
},
|
||||
addClusterHook: async () => {
|
||||
promiseIpc.send('navigate', findMainWebContents(), { name: "add-cluster-page" }).then((data: any) => {
|
||||
logger.debug("navigate: add-cluster-page IPC sent");
|
||||
})
|
||||
},
|
||||
showWhatsNewHook: async () => {
|
||||
promiseIpc.send('navigate', findMainWebContents(), { name: "whats-new-page" }).then((data: any) => {
|
||||
logger.debug("navigate: whats-new-page IPC sent");
|
||||
})
|
||||
},
|
||||
clusterSettingsHook: async () => {
|
||||
promiseIpc.send('navigate', findMainWebContents(), { name: "cluster-settings-page" }).then((data: any) => {
|
||||
logger.debug("navigate: cluster-settings-page IPC sent");
|
||||
})
|
||||
},
|
||||
}, promiseIpc)
|
||||
// create window manager and open app
|
||||
windowManager = new WindowManager(proxyPort);
|
||||
}
|
||||
|
||||
app.on("ready", main)
|
||||
app.on('window-all-closed', function () {
|
||||
// On OS X it is common for applications and their menu bar
|
||||
// to stay active until the user quits explicitly with Cmd + Q
|
||||
if (!isMac) {
|
||||
app.quit();
|
||||
} else {
|
||||
windowManager = null
|
||||
if (clusterManager) clusterManager.stop()
|
||||
}
|
||||
})
|
||||
app.on("activate", () => {
|
||||
if (!windowManager) {
|
||||
logger.debug("activate main window")
|
||||
windowManager = new WindowManager({ showSplash: false })
|
||||
windowManager.showMain(vmURL)
|
||||
}
|
||||
})
|
||||
app.on("ready", main);
|
||||
|
||||
app.on("will-quit", async (event) => {
|
||||
event.preventDefault(); // To allow mixpanel sending to be executed
|
||||
if (clusterManager) clusterManager.stop()
|
||||
if (proxyServer) proxyServer.close()
|
||||
app.exit(0);
|
||||
if (clusterManager) clusterManager.stop()
|
||||
app.exit();
|
||||
})
|
||||
-153
@@ -1,153 +0,0 @@
|
||||
import * as k8s from "@kubernetes/client-node"
|
||||
import * as os from "os"
|
||||
import * as yaml from "js-yaml"
|
||||
import logger from "./logger";
|
||||
|
||||
const kc = new k8s.KubeConfig()
|
||||
|
||||
function resolveTilde(filePath: string) {
|
||||
if (filePath[0] === "~" && (filePath[1] === "/" || filePath.length === 1)) {
|
||||
return filePath.replace("~", os.homedir());
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function loadConfig(kubeconfig: string): k8s.KubeConfig {
|
||||
if (kubeconfig) {
|
||||
kc.loadFromFile(resolveTilde(kubeconfig))
|
||||
} else {
|
||||
kc.loadFromDefault();
|
||||
}
|
||||
return kc
|
||||
}
|
||||
|
||||
/**
|
||||
* KubeConfig is valid when there's atleast one of each defined:
|
||||
* - User
|
||||
* - Cluster
|
||||
* - Context
|
||||
*
|
||||
* @param config KubeConfig to check
|
||||
*/
|
||||
export function validateConfig(config: k8s.KubeConfig): boolean {
|
||||
logger.debug(`validating kube config: ${JSON.stringify(config)}`)
|
||||
if(!config.users || config.users.length == 0) {
|
||||
throw new Error("No users provided in config")
|
||||
}
|
||||
|
||||
if(!config.clusters || config.clusters.length == 0) {
|
||||
throw new Error("No clusters provided in config")
|
||||
}
|
||||
|
||||
if(!config.contexts || config.contexts.length == 0) {
|
||||
throw new Error("No contexts provided in config")
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Breaks kube config into several configs. Each context as it own KubeConfig object
|
||||
*
|
||||
* @param configString yaml string of kube config
|
||||
*/
|
||||
export function splitConfig(kubeConfig: k8s.KubeConfig): k8s.KubeConfig[] {
|
||||
const configs: k8s.KubeConfig[] = []
|
||||
if(!kubeConfig.contexts) {
|
||||
return configs;
|
||||
}
|
||||
kubeConfig.contexts.forEach(ctx => {
|
||||
const kc = new k8s.KubeConfig();
|
||||
kc.clusters = [kubeConfig.getCluster(ctx.cluster)].filter(n => n);
|
||||
kc.users = [kubeConfig.getUser(ctx.user)].filter(n => n)
|
||||
kc.contexts = [kubeConfig.getContextObject(ctx.name)].filter(n => n)
|
||||
kc.setCurrentContext(ctx.name);
|
||||
|
||||
configs.push(kc);
|
||||
});
|
||||
return configs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads KubeConfig from a yaml and breaks it into several configs. Each context per KubeConfig object
|
||||
*
|
||||
* @param configPath path to kube config yaml file
|
||||
*/
|
||||
export function loadAndSplitConfig(configPath: string): k8s.KubeConfig[] {
|
||||
const allConfigs = new k8s.KubeConfig();
|
||||
allConfigs.loadFromFile(configPath);
|
||||
return splitConfig(allConfigs);
|
||||
}
|
||||
|
||||
export function dumpConfigYaml(kc: k8s.KubeConfig): string {
|
||||
const config = {
|
||||
apiVersion: "v1",
|
||||
kind: "Config",
|
||||
preferences: {},
|
||||
'current-context': kc.currentContext,
|
||||
clusters: kc.clusters.map(c => {
|
||||
return {
|
||||
name: c.name,
|
||||
cluster: {
|
||||
'certificate-authority-data': c.caData,
|
||||
'certificate-authority': c.caFile,
|
||||
server: c.server,
|
||||
'insecure-skip-tls-verify': c.skipTLSVerify
|
||||
}
|
||||
}
|
||||
}),
|
||||
contexts: kc.contexts.map(c => {
|
||||
return {
|
||||
name: c.name,
|
||||
context: {
|
||||
cluster: c.cluster,
|
||||
user: c.user,
|
||||
namespace: c.namespace
|
||||
}
|
||||
}
|
||||
}),
|
||||
users: kc.users.map(u => {
|
||||
return {
|
||||
name: u.name,
|
||||
user: {
|
||||
'client-certificate-data': u.certData,
|
||||
'client-certificate': u.certFile,
|
||||
'client-key-data': u.keyData,
|
||||
'client-key': u.keyFile,
|
||||
'auth-provider': u.authProvider,
|
||||
exec: u.exec,
|
||||
token: u.token,
|
||||
username: u.username,
|
||||
password: u.password
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
console.log("dumping kc:", config);
|
||||
|
||||
// skipInvalid: true makes dump ignore undefined values
|
||||
return yaml.safeDump(config, {skipInvalid: true});
|
||||
}
|
||||
|
||||
export function podHasIssues(pod: k8s.V1Pod) {
|
||||
// Logic adapted from dashboard
|
||||
const notReady = !!pod.status.conditions.find(condition => {
|
||||
return condition.type == "Ready" && condition.status !== "True"
|
||||
});
|
||||
|
||||
return (
|
||||
notReady ||
|
||||
pod.status.phase !== "Running" ||
|
||||
pod.spec.priority > 500000 // We're interested in high prio pods events regardless of their running status
|
||||
)
|
||||
}
|
||||
|
||||
// Logic adapted from dashboard
|
||||
// see: https://github.com/kontena/kontena-k8s-dashboard/blob/7d8f9cb678cc817a22dd1886c5e79415b212b9bf/client/api/endpoints/nodes.api.ts#L147
|
||||
export function getNodeWarningConditions(node: k8s.V1Node) {
|
||||
return node.status.conditions.filter(c =>
|
||||
c.status.toLowerCase() === "true" && c.type !== "Ready" && c.type !== "HostUpgrades"
|
||||
)
|
||||
}
|
||||
+44
-37
@@ -1,10 +1,14 @@
|
||||
import { spawn, ChildProcess } from "child_process"
|
||||
import { ChildProcess, spawn } from "child_process"
|
||||
import { waitUntilUsed } from "tcp-port-used";
|
||||
import { broadcastIpc } from "../common/ipc";
|
||||
import type { Cluster } from "./cluster"
|
||||
import { bundledKubectl, Kubectl } from "./kubectl"
|
||||
import logger from "./logger"
|
||||
import * as tcpPortUsed from "tcp-port-used"
|
||||
import { Kubectl, bundledKubectl } from "./kubectl"
|
||||
import { Cluster } from "./cluster"
|
||||
import { PromiseIpc } from "electron-promise-ipc"
|
||||
import { findMainWebContents } from "./webcontents"
|
||||
|
||||
export interface KubeAuthProxyLog {
|
||||
data: string;
|
||||
error?: boolean; // stream=stderr
|
||||
}
|
||||
|
||||
export class KubeAuthProxy {
|
||||
public lastError: string
|
||||
@@ -14,58 +18,52 @@ export class KubeAuthProxy {
|
||||
protected proxyProcess: ChildProcess
|
||||
protected port: number
|
||||
protected kubectl: Kubectl
|
||||
protected promiseIpc: any
|
||||
|
||||
constructor(cluster: Cluster, port: number, env: NodeJS.ProcessEnv) {
|
||||
this.env = env
|
||||
this.port = port
|
||||
this.cluster = cluster
|
||||
this.kubectl = bundledKubectl
|
||||
this.promiseIpc = new PromiseIpc({ timeout: 2000 })
|
||||
}
|
||||
|
||||
public async run(): Promise<void> {
|
||||
if (this.proxyProcess) {
|
||||
return;
|
||||
}
|
||||
const proxyBin = await this.kubectl.kubectlPath()
|
||||
let args = [
|
||||
const proxyBin = await this.kubectl.getPath()
|
||||
const args = [
|
||||
"proxy",
|
||||
"-p", this.port.toString(),
|
||||
"--kubeconfig", this.cluster.kubeConfigPath,
|
||||
"--context", this.cluster.contextName,
|
||||
"-p", `${this.port}`,
|
||||
"--kubeconfig", `${this.cluster.kubeConfigPath}`,
|
||||
"--context", `${this.cluster.contextName}`,
|
||||
"--accept-hosts", ".*",
|
||||
"--reject-paths", "^[^/]"
|
||||
]
|
||||
if (process.env.DEBUG_PROXY === "true") {
|
||||
args = args.concat(["-v", "9"])
|
||||
args.push("-v", "9")
|
||||
}
|
||||
logger.debug(`spawning kubectl proxy with args: ${args}`)
|
||||
this.proxyProcess = spawn(proxyBin, args, {
|
||||
env: this.env
|
||||
})
|
||||
this.proxyProcess = spawn(proxyBin, args, { env: this.env, })
|
||||
|
||||
this.proxyProcess.on("exit", (code) => {
|
||||
logger.error(`proxy ${this.cluster.contextName} exited with code ${code}`)
|
||||
this.sendIpcLogMessage( `proxy exited with code ${code}`, "stderr").catch((err: Error) => {
|
||||
logger.debug("failed to send IPC log message: " + err.message)
|
||||
})
|
||||
this.proxyProcess = null
|
||||
this.sendIpcLogMessage({ data: `proxy exited with code: ${code}`, error: code > 0 })
|
||||
this.exit();
|
||||
})
|
||||
|
||||
this.proxyProcess.stdout.on('data', (data) => {
|
||||
let logItem = data.toString()
|
||||
if (logItem.startsWith("Starting to serve on")) {
|
||||
logItem = "Authentication proxy started\n"
|
||||
}
|
||||
logger.debug(`proxy ${this.cluster.contextName} stdout: ${logItem}`)
|
||||
this.sendIpcLogMessage(logItem, "stdout")
|
||||
})
|
||||
this.proxyProcess.stderr.on('data', (data) => {
|
||||
this.lastError = this.parseError(data.toString())
|
||||
logger.debug(`proxy ${this.cluster.contextName} stderr: ${data}`)
|
||||
this.sendIpcLogMessage(data.toString(), "stderr")
|
||||
this.sendIpcLogMessage({ data: logItem })
|
||||
})
|
||||
|
||||
return tcpPortUsed.waitUntilUsed(this.port, 500, 10000)
|
||||
this.proxyProcess.stderr.on('data', (data) => {
|
||||
this.lastError = this.parseError(data.toString())
|
||||
this.sendIpcLogMessage({ data: data.toString(), error: true })
|
||||
})
|
||||
|
||||
return waitUntilUsed(this.port, 500, 10000)
|
||||
}
|
||||
|
||||
protected parseError(data: string) {
|
||||
@@ -76,21 +74,30 @@ export class KubeAuthProxy {
|
||||
try {
|
||||
const parsedError = JSON.parse(jsonError)
|
||||
errorMsg = parsedError.error_description || parsedError.error || jsonError
|
||||
} catch(_) {
|
||||
} catch (_) {
|
||||
errorMsg = jsonError.trim()
|
||||
}
|
||||
}
|
||||
return errorMsg
|
||||
}
|
||||
|
||||
protected async sendIpcLogMessage(data: string, stream: string) {
|
||||
await this.promiseIpc.send(`kube-auth:${this.cluster.id}`, findMainWebContents(), { data, stream })
|
||||
protected async sendIpcLogMessage(res: KubeAuthProxyLog) {
|
||||
const channel = `kube-auth:${this.cluster.id}`
|
||||
logger.info(`[KUBE-AUTH]: out-channel "${channel}"`, { ...res, meta: this.cluster.getMeta() });
|
||||
broadcastIpc({
|
||||
// webContentId: null, // todo: send a message only to single cluster's window
|
||||
channel: channel,
|
||||
args: [res],
|
||||
});
|
||||
}
|
||||
|
||||
public exit() {
|
||||
if (this.proxyProcess) {
|
||||
logger.debug(`Stopping local proxy: ${this.cluster.contextName}`)
|
||||
this.proxyProcess.kill()
|
||||
}
|
||||
if (!this.proxyProcess) return;
|
||||
logger.debug("[KUBE-AUTH]: stopping local proxy", this.cluster.getMeta())
|
||||
this.proxyProcess.kill()
|
||||
this.proxyProcess.removeAllListeners();
|
||||
this.proxyProcess.stderr.removeAllListeners();
|
||||
this.proxyProcess.stdout.removeAllListeners();
|
||||
this.proxyProcess = null;
|
||||
}
|
||||
}
|
||||
@@ -1,62 +1,75 @@
|
||||
import type { KubeConfig } from "@kubernetes/client-node";
|
||||
import type { Cluster } from "./cluster"
|
||||
import type { ContextHandler } from "./context-handler";
|
||||
import { app } from "electron"
|
||||
import fs from "fs"
|
||||
import { ensureDir, randomFileName} from "./file-helpers"
|
||||
import path from "path"
|
||||
import fs from "fs-extra"
|
||||
import { dumpConfigYaml, loadConfig } from "../common/kube-helpers"
|
||||
import logger from "./logger"
|
||||
import { Cluster } from "./cluster"
|
||||
import * as k8s from "./k8s"
|
||||
import { KubeConfig } from "@kubernetes/client-node"
|
||||
|
||||
export class KubeconfigManager {
|
||||
protected configDir = app.getPath("temp")
|
||||
protected tempFile: string
|
||||
protected cluster: Cluster
|
||||
protected tempFile: string;
|
||||
|
||||
constructor(cluster: Cluster) {
|
||||
this.cluster = cluster
|
||||
this.tempFile = this.createTemporaryKubeconfig()
|
||||
constructor(protected cluster: Cluster, protected contextHandler: ContextHandler) {
|
||||
this.init();
|
||||
}
|
||||
|
||||
public getPath() {
|
||||
return this.tempFile
|
||||
protected async init() {
|
||||
try {
|
||||
await this.contextHandler.ensurePort();
|
||||
await this.createProxyKubeconfig();
|
||||
} catch (err) {
|
||||
logger.error(`Failed to created temp config for auth-proxy`, { err })
|
||||
}
|
||||
}
|
||||
|
||||
getPath() {
|
||||
return this.tempFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates new "temporary" kubeconfig that point to the kubectl-proxy.
|
||||
* Creates new "temporary" kubeconfig that point to the kubectl-proxy.
|
||||
* This way any user of the config does not need to know anything about the auth etc. details.
|
||||
*/
|
||||
protected createTemporaryKubeconfig(): string {
|
||||
ensureDir(this.configDir)
|
||||
const path = `${this.configDir}/${randomFileName("kubeconfig")}`
|
||||
const originalKc = new KubeConfig()
|
||||
originalKc.loadFromFile(this.cluster.kubeConfigPath)
|
||||
const kc = {
|
||||
protected async createProxyKubeconfig(): Promise<string> {
|
||||
const { configDir, cluster, contextHandler } = this;
|
||||
const { contextName, kubeConfigPath, id } = cluster;
|
||||
const tempFile = path.join(configDir, `kubeconfig-${id}`);
|
||||
const kubeConfig = loadConfig(kubeConfigPath);
|
||||
const proxyConfig: Partial<KubeConfig> = {
|
||||
currentContext: contextName,
|
||||
clusters: [
|
||||
{
|
||||
name: this.cluster.contextName,
|
||||
server: `http://127.0.0.1:${this.cluster.contextHandler.proxyPort}`
|
||||
name: contextName,
|
||||
server: await contextHandler.resolveAuthProxyUrl(),
|
||||
skipTLSVerify: undefined,
|
||||
}
|
||||
],
|
||||
users: [
|
||||
{
|
||||
name: "proxy"
|
||||
}
|
||||
{ name: "proxy" },
|
||||
],
|
||||
contexts: [
|
||||
{
|
||||
name: this.cluster.contextName,
|
||||
cluster: this.cluster.contextName,
|
||||
namespace: originalKc.getContextObject(this.cluster.contextName).namespace,
|
||||
user: "proxy"
|
||||
user: "proxy",
|
||||
name: contextName,
|
||||
cluster: contextName,
|
||||
namespace: kubeConfig.getContextObject(contextName).namespace,
|
||||
}
|
||||
],
|
||||
currentContext: this.cluster.contextName
|
||||
} as KubeConfig
|
||||
fs.writeFileSync(path, k8s.dumpConfigYaml(kc))
|
||||
return path
|
||||
]
|
||||
};
|
||||
|
||||
// write
|
||||
const configYaml = dumpConfigYaml(proxyConfig);
|
||||
fs.ensureDir(path.dirname(tempFile));
|
||||
fs.writeFileSync(tempFile, configYaml);
|
||||
this.tempFile = tempFile;
|
||||
logger.debug(`Created temp kubeconfig "${contextName}" at "${tempFile}": \n${configYaml}`);
|
||||
return tempFile;
|
||||
}
|
||||
|
||||
public unlink() {
|
||||
logger.debug('Deleting temporary kubeconfig: ' + this.tempFile)
|
||||
unlink() {
|
||||
logger.info('Deleting temporary kubeconfig: ' + this.tempFile)
|
||||
fs.unlinkSync(this.tempFile)
|
||||
}
|
||||
}
|
||||
+47
-43
@@ -1,15 +1,15 @@
|
||||
import { app, remote } from "electron"
|
||||
import path from "path"
|
||||
import fs from "fs"
|
||||
import request from "request"
|
||||
import { promiseExec} from "./promise-exec"
|
||||
import { promiseExec } from "./promise-exec"
|
||||
import logger from "./logger"
|
||||
import { ensureDir, pathExists } from "fs-extra"
|
||||
import { globalRequestOpts } from "../common/request"
|
||||
import * as lockFile from "proper-lockfile"
|
||||
import { helmCli } from "./helm-cli"
|
||||
import { helmCli } from "./helm/helm-cli"
|
||||
import { userStore } from "../common/user-store"
|
||||
import { getBundledKubectlVersion} from "../common/utils/app-version"
|
||||
import { customRequest } from "../common/request";
|
||||
import { getBundledKubectlVersion } from "../common/utils/app-version"
|
||||
import { isDevelopment, isWindows } from "../common/vars";
|
||||
|
||||
const bundledVersion = getBundledKubectlVersion()
|
||||
const kubectlMap: Map<string, string> = new Map([
|
||||
@@ -32,35 +32,37 @@ const packageMirrors: Map<string, string> = new Map([
|
||||
["china", "https://mirror.azure.cn/kubernetes/kubectl"]
|
||||
])
|
||||
|
||||
let bundledPath: string
|
||||
const initScriptVersionString = "# lens-initscript v3\n"
|
||||
|
||||
const isDevelopment = process.env.NODE_ENV !== "production"
|
||||
let bundledPath: string = null
|
||||
|
||||
if(isDevelopment) {
|
||||
if (isDevelopment) {
|
||||
bundledPath = path.join(process.cwd(), "binaries", "client", process.platform, process.arch, "kubectl")
|
||||
} else {
|
||||
bundledPath = path.join(process.resourcesPath, process.arch, "kubectl")
|
||||
}
|
||||
|
||||
if(process.platform === "win32") bundledPath = `${bundledPath}.exe`
|
||||
if (isWindows) {
|
||||
bundledPath = `${bundledPath}.exe`
|
||||
}
|
||||
|
||||
export class Kubectl {
|
||||
|
||||
public kubectlVersion: string
|
||||
protected directory: string
|
||||
protected url: string
|
||||
protected path: string
|
||||
protected dirname: string
|
||||
|
||||
public static readonly kubectlDir = path.join((app || remote.app).getPath("userData"), "binaries", "kubectl")
|
||||
static get kubectlDir() {
|
||||
return path.join((app || remote.app).getPath("userData"), "binaries", "kubectl")
|
||||
}
|
||||
|
||||
public static readonly bundledKubectlPath = bundledPath
|
||||
public static readonly bundledKubectlVersion: string = bundledVersion
|
||||
private static bundledInstance: Kubectl;
|
||||
|
||||
// Returns the single bundled Kubectl instance
|
||||
public static bundled() {
|
||||
if(!Kubectl.bundledInstance) Kubectl.bundledInstance = new Kubectl(Kubectl.bundledKubectlVersion)
|
||||
if (!Kubectl.bundledInstance) Kubectl.bundledInstance = new Kubectl(Kubectl.bundledKubectlVersion)
|
||||
return Kubectl.bundledInstance
|
||||
}
|
||||
|
||||
@@ -69,7 +71,7 @@ export class Kubectl {
|
||||
const minorVersion = versionParts[1]
|
||||
/* minorVersion is the first two digits of kube server version
|
||||
if the version map includes that, use that version, if not, fallback to the exact x.y.z of kube version */
|
||||
if(kubectlMap.has(minorVersion)) {
|
||||
if (kubectlMap.has(minorVersion)) {
|
||||
this.kubectlVersion = kubectlMap.get(minorVersion)
|
||||
logger.debug("Set kubectl version " + this.kubectlVersion + " for cluster version " + clusterVersion + " using version map")
|
||||
} else {
|
||||
@@ -79,16 +81,16 @@ export class Kubectl {
|
||||
|
||||
let arch = null
|
||||
|
||||
if(process.arch == "x64") {
|
||||
if (process.arch == "x64") {
|
||||
arch = "amd64"
|
||||
} else if(process.arch == "x86" || process.arch == "ia32") {
|
||||
} else if (process.arch == "x86" || process.arch == "ia32") {
|
||||
arch = "386"
|
||||
} else {
|
||||
arch = process.arch
|
||||
}
|
||||
|
||||
const platformName = process.platform === "win32" ? "windows" : process.platform
|
||||
const binaryName = process.platform === "win32" ? "kubectl.exe" : "kubectl"
|
||||
const platformName = isWindows ? "windows" : process.platform
|
||||
const binaryName = isWindows ? "kubectl.exe" : "kubectl"
|
||||
|
||||
this.url = `${this.getDownloadMirror()}/v${this.kubectlVersion}/bin/${platformName}/${arch}/${binaryName}`
|
||||
|
||||
@@ -96,11 +98,11 @@ export class Kubectl {
|
||||
this.path = path.join(this.dirname, binaryName)
|
||||
}
|
||||
|
||||
public async kubectlPath(): Promise<string> {
|
||||
public async getPath(): Promise<string> {
|
||||
try {
|
||||
await this.ensureKubectl()
|
||||
return this.path
|
||||
} catch(err) {
|
||||
} catch (err) {
|
||||
logger.error("Failed to ensure kubectl, fallback to the bundled version")
|
||||
logger.error(err)
|
||||
return Kubectl.bundledKubectlPath
|
||||
@@ -111,7 +113,7 @@ export class Kubectl {
|
||||
try {
|
||||
await this.ensureKubectl()
|
||||
return this.dirname
|
||||
} catch(err) {
|
||||
} catch (err) {
|
||||
logger.error(err)
|
||||
return ""
|
||||
}
|
||||
@@ -136,8 +138,7 @@ export class Kubectl {
|
||||
return true
|
||||
}
|
||||
logger.error(`Local kubectl is version ${version}, expected ${this.kubectlVersion}, unlinking`)
|
||||
}
|
||||
catch(err) {
|
||||
} catch (err) {
|
||||
logger.error(`Local kubectl failed to run properly (${err.message}), unlinking`)
|
||||
}
|
||||
await fs.promises.unlink(this.path)
|
||||
@@ -146,7 +147,7 @@ export class Kubectl {
|
||||
}
|
||||
|
||||
protected async checkBundled(): Promise<boolean> {
|
||||
if(this.kubectlVersion === Kubectl.bundledKubectlVersion) {
|
||||
if (this.kubectlVersion === Kubectl.bundledKubectlVersion) {
|
||||
try {
|
||||
const exist = await pathExists(this.path)
|
||||
if (!exist) {
|
||||
@@ -154,7 +155,7 @@ export class Kubectl {
|
||||
await fs.promises.chmod(this.path, 0o755)
|
||||
}
|
||||
return true
|
||||
} catch(err) {
|
||||
} catch (err) {
|
||||
logger.error("Could not copy the bundled kubectl to app-data: " + err)
|
||||
return false
|
||||
}
|
||||
@@ -169,10 +170,15 @@ export class Kubectl {
|
||||
logger.debug(`Acquired a lock for ${this.kubectlVersion}`)
|
||||
const bundled = await this.checkBundled()
|
||||
const isValid = await this.checkBinary(!bundled)
|
||||
if(!isValid) {
|
||||
await this.downloadKubectl().catch((error) => { logger.error(error) });
|
||||
if (!isValid) {
|
||||
await this.downloadKubectl().catch((error) => {
|
||||
logger.error(error)
|
||||
});
|
||||
}
|
||||
await this.writeInitScripts().catch((error) => { logger.error("Failed to write init scripts"); logger.error(error) })
|
||||
await this.writeInitScripts().catch((error) => {
|
||||
logger.error("Failed to write init scripts");
|
||||
logger.error(error)
|
||||
})
|
||||
logger.debug(`Releasing lock for ${this.kubectlVersion}`)
|
||||
release()
|
||||
return true
|
||||
@@ -188,10 +194,10 @@ export class Kubectl {
|
||||
|
||||
logger.info(`Downloading kubectl ${this.kubectlVersion} from ${this.url} to ${this.path}`)
|
||||
return new Promise((resolve, reject) => {
|
||||
const stream = request({
|
||||
const stream = customRequest({
|
||||
url: this.url,
|
||||
gzip: true,
|
||||
...this.getRequestOpts()
|
||||
})
|
||||
});
|
||||
const file = fs.createWriteStream(this.path)
|
||||
stream.on("complete", () => {
|
||||
logger.debug("kubectl binary download finished")
|
||||
@@ -199,12 +205,16 @@ export class Kubectl {
|
||||
})
|
||||
stream.on("error", (error) => {
|
||||
logger.error(error)
|
||||
fs.unlink(this.path, null)
|
||||
fs.unlink(this.path, () => {
|
||||
// do nothing
|
||||
})
|
||||
reject(error)
|
||||
})
|
||||
file.on("close", () => {
|
||||
logger.debug("kubectl binary download closed")
|
||||
fs.chmod(this.path, 0o755, null)
|
||||
fs.chmod(this.path, 0o755, (err) => {
|
||||
if (err) reject(err);
|
||||
})
|
||||
resolve()
|
||||
})
|
||||
stream.pipe(file)
|
||||
@@ -213,7 +223,7 @@ export class Kubectl {
|
||||
|
||||
protected async scriptIsLatest(scriptPath: string) {
|
||||
const scriptExists = await pathExists(scriptPath)
|
||||
if(!scriptExists) return false
|
||||
if (!scriptExists) return false
|
||||
|
||||
try {
|
||||
const filehandle = await fs.promises.open(scriptPath, 'r')
|
||||
@@ -232,7 +242,7 @@ export class Kubectl {
|
||||
const fsPromises = fs.promises;
|
||||
const bashScriptPath = path.join(this.dirname, '.bash_set_path')
|
||||
const bashScriptIsLatest = await this.scriptIsLatest(bashScriptPath)
|
||||
if(!bashScriptIsLatest) {
|
||||
if (!bashScriptIsLatest) {
|
||||
let bashScript = "" + initScriptVersionString
|
||||
bashScript += "tempkubeconfig=\"$KUBECONFIG\"\n"
|
||||
bashScript += "test -f \"/etc/profile\" && . \"/etc/profile\"\n"
|
||||
@@ -251,7 +261,7 @@ export class Kubectl {
|
||||
|
||||
const zshScriptPath = path.join(this.dirname, '.zlogin')
|
||||
const zshScriptIsLatest = await this.scriptIsLatest(zshScriptPath)
|
||||
if(!zshScriptIsLatest) {
|
||||
if (!zshScriptIsLatest) {
|
||||
let zshScript = "" + initScriptVersionString
|
||||
|
||||
zshScript += "tempkubeconfig=\"$KUBECONFIG\"\n"
|
||||
@@ -278,14 +288,8 @@ export class Kubectl {
|
||||
}
|
||||
}
|
||||
|
||||
protected getRequestOpts() {
|
||||
return globalRequestOpts({
|
||||
url: this.url
|
||||
})
|
||||
}
|
||||
|
||||
protected getDownloadMirror() {
|
||||
const mirror = packageMirrors.get(userStore.getPreferences().downloadMirror)
|
||||
const mirror = packageMirrors.get(userStore.preferences?.downloadMirror)
|
||||
if (mirror) {
|
||||
return mirror
|
||||
}
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import packageInfo from "../../package.json"
|
||||
import { bundledKubectl, Kubectl } from "../../src/main/kubectl";
|
||||
import { UserStore } from "../common/user-store";
|
||||
|
||||
jest.mock("../common/user-store", () => {
|
||||
const userStoreMock: Partial<UserStore> = {
|
||||
getPreferences() {
|
||||
return {
|
||||
downloadMirror: "default"
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
userStore: userStoreMock,
|
||||
}
|
||||
})
|
||||
jest.mock("../common/user-store");
|
||||
|
||||
describe("kubectlVersion", () => {
|
||||
it("returns bundled version if exactly same version used", async () => {
|
||||
|
||||
@@ -164,13 +164,17 @@ export class LensBinary {
|
||||
|
||||
stream.on("error", (error) => {
|
||||
logger.error(error)
|
||||
fs.unlink(binaryPath, null)
|
||||
fs.unlink(binaryPath, () => {
|
||||
// do nothing
|
||||
})
|
||||
throw(error)
|
||||
})
|
||||
return new Promise((resolve, reject) => {
|
||||
file.on("close", () => {
|
||||
logger.debug(`${this.originalBinaryName} binary download closed`)
|
||||
if (!this.tarPath) fs.chmod(binaryPath, 0o755, null)
|
||||
if (!this.tarPath) fs.chmod(binaryPath, 0o755, (err) => {
|
||||
if (err) reject(err);
|
||||
})
|
||||
resolve()
|
||||
})
|
||||
stream.pipe(file)
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import net from "net";
|
||||
import http from "http";
|
||||
import httpProxy from "http-proxy";
|
||||
import url from "url";
|
||||
import * as WebSocket from "ws"
|
||||
import { openShell } from "./node-shell-session";
|
||||
import { Router } from "./router"
|
||||
import { ClusterManager } from "./cluster-manager"
|
||||
import { ContextHandler } from "./context-handler";
|
||||
import { apiKubePrefix } from "../common/vars";
|
||||
import logger from "./logger"
|
||||
|
||||
export class LensProxy {
|
||||
protected origin: string
|
||||
protected proxyServer: http.Server
|
||||
protected router: Router
|
||||
protected closed = false
|
||||
protected retryCounters = new Map<string, number>()
|
||||
|
||||
static create(port: number, clusterManager: ClusterManager) {
|
||||
return new LensProxy(port, clusterManager).listen();
|
||||
}
|
||||
|
||||
private constructor(protected port: number, protected clusterManager: ClusterManager) {
|
||||
this.origin = `http://localhost:${port}`
|
||||
this.router = new Router();
|
||||
}
|
||||
|
||||
listen(port = this.port): this {
|
||||
this.proxyServer = this.buildCustomProxy().listen(port);
|
||||
logger.info(`LensProxy server has started at ${this.origin}`);
|
||||
return this;
|
||||
}
|
||||
|
||||
close() {
|
||||
logger.info("Closing proxy server");
|
||||
this.proxyServer.close()
|
||||
this.closed = true
|
||||
}
|
||||
|
||||
protected buildCustomProxy(): http.Server {
|
||||
const proxy = this.createProxy();
|
||||
const customProxy = http.createServer((req: http.IncomingMessage, res: http.ServerResponse) => {
|
||||
this.handleRequest(proxy, req, res);
|
||||
});
|
||||
customProxy.on("upgrade", (req: http.IncomingMessage, socket: net.Socket, head: Buffer) => {
|
||||
this.handleWsUpgrade(req, socket, head)
|
||||
});
|
||||
customProxy.on("error", (err) => {
|
||||
logger.error("proxy error", err)
|
||||
});
|
||||
return customProxy;
|
||||
}
|
||||
|
||||
protected createProxy(): httpProxy {
|
||||
const proxy = httpProxy.createProxyServer();
|
||||
proxy.on("proxyRes", (proxyRes, req, res) => {
|
||||
if (req.method !== "GET") {
|
||||
return;
|
||||
}
|
||||
if (proxyRes.statusCode === 502) {
|
||||
const cluster = this.clusterManager.getClusterForRequest(req)
|
||||
const proxyError = cluster?.contextHandler.proxyLastError;
|
||||
if (proxyError) {
|
||||
return res.writeHead(502).end(proxyError);
|
||||
}
|
||||
}
|
||||
const reqId = this.getRequestId(req);
|
||||
if (this.retryCounters.has(reqId)) {
|
||||
logger.debug(`Resetting proxy retry cache for url: ${reqId}`);
|
||||
this.retryCounters.delete(reqId)
|
||||
}
|
||||
})
|
||||
proxy.on("error", (error, req, res, target) => {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
if (target) {
|
||||
logger.debug("Failed proxy to target: " + JSON.stringify(target, null, 2));
|
||||
if (req.method === "GET" && (!res.statusCode || res.statusCode >= 500)) {
|
||||
const reqId = this.getRequestId(req);
|
||||
const retryCount = this.retryCounters.get(reqId) || 0
|
||||
const timeoutMs = retryCount * 250
|
||||
if (retryCount < 20) {
|
||||
logger.debug(`Retrying proxy request to url: ${reqId}`)
|
||||
setTimeout(() => {
|
||||
this.retryCounters.set(reqId, retryCount + 1)
|
||||
this.handleRequest(proxy, req, res)
|
||||
}, timeoutMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
res.writeHead(500).end("Oops, something went wrong.")
|
||||
})
|
||||
|
||||
return proxy;
|
||||
}
|
||||
|
||||
protected createWsListener(): WebSocket.Server {
|
||||
const ws = new WebSocket.Server({ noServer: true })
|
||||
return ws.on("connection", ((socket: WebSocket, req: http.IncomingMessage) => {
|
||||
const cluster = this.clusterManager.getClusterForRequest(req);
|
||||
const nodeParam = url.parse(req.url, true).query["node"]?.toString();
|
||||
openShell(socket, cluster, nodeParam);
|
||||
}));
|
||||
}
|
||||
|
||||
protected async getProxyTarget(req: http.IncomingMessage, contextHandler: ContextHandler): Promise<httpProxy.ServerOptions> {
|
||||
if (req.url.startsWith(apiKubePrefix)) {
|
||||
delete req.headers.authorization
|
||||
req.url = req.url.replace(apiKubePrefix, "")
|
||||
const isWatchRequest = req.url.includes("watch=")
|
||||
return await contextHandler.getApiTarget(isWatchRequest)
|
||||
}
|
||||
}
|
||||
|
||||
protected getRequestId(req: http.IncomingMessage) {
|
||||
return req.headers.host + req.url;
|
||||
}
|
||||
|
||||
protected async handleRequest(proxy: httpProxy, req: http.IncomingMessage, res: http.ServerResponse) {
|
||||
const cluster = this.clusterManager.getClusterForRequest(req)
|
||||
if (cluster) {
|
||||
await cluster.contextHandler.ensureServer();
|
||||
const proxyTarget = await this.getProxyTarget(req, cluster.contextHandler)
|
||||
if (proxyTarget) {
|
||||
// allow to fetch apis in "clusterId.localhost:port" from "localhost:port"
|
||||
res.setHeader("Access-Control-Allow-Origin", this.origin);
|
||||
return proxy.web(req, res, proxyTarget);
|
||||
}
|
||||
}
|
||||
this.router.route(cluster, req, res);
|
||||
}
|
||||
|
||||
protected async handleWsUpgrade(req: http.IncomingMessage, socket: net.Socket, head: Buffer) {
|
||||
const wsServer = this.createWsListener();
|
||||
wsServer.handleUpgrade(req, socket, head, (con) => {
|
||||
wsServer.emit("connection", con, req);
|
||||
});
|
||||
}
|
||||
}
|
||||
+102
-95
@@ -1,60 +1,75 @@
|
||||
import { app, BrowserWindow, dialog, Menu, MenuItem, MenuItemConstructorOptions, shell, webContents } from "electron"
|
||||
import { isDevelopment, isMac, issuesTrackerUrl, isWindows, slackUrl } from "../common/vars";
|
||||
import { autorun } from "mobx";
|
||||
import { WindowManager } from "./window-manager";
|
||||
import { appName, isMac, issuesTrackerUrl, isWindows, slackUrl } from "../common/vars";
|
||||
import { addClusterURL } from "../renderer/components/+add-cluster/add-cluster.route";
|
||||
import { preferencesURL } from "../renderer/components/+preferences/preferences.route";
|
||||
import { whatsNewURL } from "../renderer/components/+whats-new/whats-new.route";
|
||||
import { clusterSettingsURL } from "../renderer/components/+cluster-settings/cluster-settings.route";
|
||||
import logger from "./logger";
|
||||
|
||||
// todo: refactor + split menu sections to separated files, e.g. menus/file.menu.ts
|
||||
|
||||
export interface MenuOptions {
|
||||
logoutHook: any;
|
||||
addClusterHook: any;
|
||||
clusterSettingsHook: any;
|
||||
showWhatsNewHook: any;
|
||||
showPreferencesHook: any;
|
||||
// all the above are really () => void type functions
|
||||
export function initMenu(windowManager: WindowManager) {
|
||||
autorun(() => buildMenu(windowManager), {
|
||||
delay: 100
|
||||
});
|
||||
}
|
||||
|
||||
function setClusterSettingsEnabled(enabled: boolean) {
|
||||
const menuIndex = isMac ? 1 : 0
|
||||
Menu.getApplicationMenu().items[menuIndex].submenu.items[1].enabled = enabled
|
||||
}
|
||||
export function buildMenu(windowManager: WindowManager) {
|
||||
function ignoreOnMac(menuItems: MenuItemConstructorOptions[]) {
|
||||
if (isMac) return [];
|
||||
return menuItems;
|
||||
}
|
||||
|
||||
function showAbout(_menuitem: MenuItem, browserWindow: BrowserWindow) {
|
||||
const appDetails = [
|
||||
`Version: ${app.getVersion()}`,
|
||||
]
|
||||
appDetails.push(`Copyright 2020 Mirantis, Inc.`)
|
||||
let title = "Lens"
|
||||
if (isWindows) {
|
||||
title = ` ${title}`
|
||||
function activeClusterOnly(menuItems: MenuItemConstructorOptions[]) {
|
||||
if (!windowManager.activeClusterId) {
|
||||
menuItems.forEach(item => {
|
||||
item.enabled = false
|
||||
});
|
||||
}
|
||||
return menuItems;
|
||||
}
|
||||
|
||||
function navigate(url: string) {
|
||||
logger.info(`[MENU]: navigating to ${url}`);
|
||||
windowManager.navigate({
|
||||
channel: "menu:navigate",
|
||||
url: url,
|
||||
})
|
||||
}
|
||||
|
||||
function showAbout(browserWindow: BrowserWindow) {
|
||||
const appInfo = [
|
||||
`${appName}: ${app.getVersion()}`,
|
||||
`Electron: ${process.versions.electron}`,
|
||||
`Chrome: ${process.versions.chrome}`,
|
||||
`Copyright 2020 Copyright 2020 Mirantis, Inc.`,
|
||||
]
|
||||
dialog.showMessageBoxSync(browserWindow, {
|
||||
title: `${isWindows ? " ".repeat(2) : ""}${appName}`,
|
||||
type: "info",
|
||||
buttons: ["Close"],
|
||||
message: `Lens`,
|
||||
detail: appInfo.join("\r\n")
|
||||
})
|
||||
}
|
||||
dialog.showMessageBoxSync(browserWindow, {
|
||||
title,
|
||||
type: "info",
|
||||
buttons: ["Close"],
|
||||
message: `Lens`,
|
||||
detail: appDetails.join("\r\n")
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs the menu based on the example at: https://electronjs.org/docs/api/menu#main-process
|
||||
* Menu items are constructed piece-by-piece to have slightly better control on individual sub-menus
|
||||
*
|
||||
* @param ipc the main promiceIpc handle. Needed to be able to hook IPC sending into logout click handler.
|
||||
*/
|
||||
export default function initMenu(opts: MenuOptions, promiseIpc: any) {
|
||||
const mt: MenuItemConstructorOptions[] = [];
|
||||
|
||||
const macAppMenu: MenuItemConstructorOptions = {
|
||||
label: app.getName(),
|
||||
submenu: [
|
||||
{
|
||||
label: "About Lens",
|
||||
click: showAbout
|
||||
click(menuItem: MenuItem, browserWindow: BrowserWindow) {
|
||||
showAbout(browserWindow)
|
||||
}
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Preferences',
|
||||
click: opts.showPreferencesHook,
|
||||
enabled: true
|
||||
click() {
|
||||
navigate(preferencesURL())
|
||||
}
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ role: 'services' },
|
||||
@@ -66,51 +81,46 @@ export default function initMenu(opts: MenuOptions, promiseIpc: any) {
|
||||
{ role: 'quit' }
|
||||
]
|
||||
};
|
||||
|
||||
if (isMac) {
|
||||
mt.push(macAppMenu);
|
||||
}
|
||||
|
||||
let fileMenu: MenuItemConstructorOptions;
|
||||
if (isMac) {
|
||||
fileMenu = {
|
||||
label: 'File',
|
||||
submenu: [{
|
||||
label: 'Add Cluster...',
|
||||
click: opts.addClusterHook,
|
||||
},
|
||||
const fileMenu: MenuItemConstructorOptions = {
|
||||
label: "File",
|
||||
submenu: [
|
||||
{
|
||||
label: 'Cluster Settings',
|
||||
click: opts.clusterSettingsHook,
|
||||
enabled: false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
else {
|
||||
fileMenu = {
|
||||
label: 'File',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Add Cluster...',
|
||||
click: opts.addClusterHook,
|
||||
},
|
||||
label: 'Add Cluster',
|
||||
click() {
|
||||
navigate(addClusterURL())
|
||||
}
|
||||
},
|
||||
...activeClusterOnly([
|
||||
{
|
||||
label: 'Cluster Settings',
|
||||
click: opts.clusterSettingsHook,
|
||||
enabled: false
|
||||
},
|
||||
click() {
|
||||
navigate(clusterSettingsURL({
|
||||
params: {
|
||||
clusterId: windowManager.activeClusterId
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
]),
|
||||
...ignoreOnMac([
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Preferences',
|
||||
click: opts.showPreferencesHook,
|
||||
enabled: true
|
||||
click() {
|
||||
navigate(preferencesURL())
|
||||
}
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ role: 'quit' }
|
||||
]
|
||||
}
|
||||
}
|
||||
mt.push(fileMenu);
|
||||
])
|
||||
]
|
||||
};
|
||||
mt.push(fileMenu)
|
||||
|
||||
const editMenu: MenuItemConstructorOptions = {
|
||||
label: 'Edit',
|
||||
@@ -126,8 +136,7 @@ export default function initMenu(opts: MenuOptions, promiseIpc: any) {
|
||||
{ role: 'selectAll' },
|
||||
]
|
||||
};
|
||||
mt.push(editMenu);
|
||||
|
||||
mt.push(editMenu)
|
||||
const viewMenu: MenuItemConstructorOptions = {
|
||||
label: 'View',
|
||||
submenu: [
|
||||
@@ -135,21 +144,21 @@ export default function initMenu(opts: MenuOptions, promiseIpc: any) {
|
||||
label: 'Back',
|
||||
accelerator: 'CmdOrCtrl+[',
|
||||
click() {
|
||||
webContents.getFocusedWebContents().executeJavaScript('window.history.back()')
|
||||
webContents.getFocusedWebContents()?.goBack();
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Forward',
|
||||
accelerator: 'CmdOrCtrl+]',
|
||||
click() {
|
||||
webContents.getFocusedWebContents().executeJavaScript('window.history.forward()')
|
||||
webContents.getFocusedWebContents()?.goForward();
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Reload',
|
||||
accelerator: 'CmdOrCtrl+R',
|
||||
click() {
|
||||
webContents.getFocusedWebContents().reload()
|
||||
webContents.getFocusedWebContents()?.reload();
|
||||
}
|
||||
},
|
||||
{ role: 'toggleDevTools' },
|
||||
@@ -161,19 +170,19 @@ export default function initMenu(opts: MenuOptions, promiseIpc: any) {
|
||||
{ role: 'togglefullscreen' }
|
||||
]
|
||||
};
|
||||
mt.push(viewMenu);
|
||||
mt.push(viewMenu)
|
||||
|
||||
const helpMenu: MenuItemConstructorOptions = {
|
||||
role: 'help',
|
||||
submenu: [
|
||||
{
|
||||
label: 'License',
|
||||
label: "License",
|
||||
click: async () => {
|
||||
shell.openExternal('https://k8slens.dev/licenses/eula.md');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Community Slack',
|
||||
label: "Community Slack",
|
||||
click: async () => {
|
||||
shell.openExternal(slackUrl);
|
||||
},
|
||||
@@ -186,24 +195,22 @@ export default function initMenu(opts: MenuOptions, promiseIpc: any) {
|
||||
},
|
||||
{
|
||||
label: "What's new?",
|
||||
click: opts.showWhatsNewHook,
|
||||
click() {
|
||||
navigate(whatsNewURL())
|
||||
},
|
||||
},
|
||||
...(!isMac ? [{
|
||||
label: "About Lens",
|
||||
click: showAbout
|
||||
} as MenuItemConstructorOptions] : [])
|
||||
...ignoreOnMac([
|
||||
{
|
||||
label: "About Lens",
|
||||
click(menuItem: MenuItem, browserWindow: BrowserWindow) {
|
||||
showAbout(browserWindow)
|
||||
}
|
||||
}
|
||||
])
|
||||
]
|
||||
};
|
||||
mt.push(helpMenu);
|
||||
|
||||
const menu = Menu.buildFromTemplate(mt);
|
||||
Menu.setApplicationMenu(menu);
|
||||
mt.push(helpMenu)
|
||||
|
||||
promiseIpc.on("enableClusterSettingsMenuItem", (clusterId: string) => {
|
||||
setClusterSettingsEnabled(true)
|
||||
});
|
||||
|
||||
promiseIpc.on("disableClusterSettingsMenuItem", () => {
|
||||
setClusterSettingsEnabled(false)
|
||||
});
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(mt));
|
||||
}
|
||||
@@ -3,25 +3,25 @@ import * as pty from "node-pty"
|
||||
import { ShellSession } from "./shell-session";
|
||||
import { v4 as uuid } from "uuid"
|
||||
import * as k8s from "@kubernetes/client-node"
|
||||
import { KubeConfig } from "@kubernetes/client-node"
|
||||
import { Cluster } from "./cluster"
|
||||
import logger from "./logger";
|
||||
import { KubeConfig, V1Pod } from "@kubernetes/client-node";
|
||||
import { tracker } from "./tracker"
|
||||
import { Cluster, ClusterPreferences } from "./cluster"
|
||||
import { tracker } from "../common/tracker";
|
||||
|
||||
export class NodeShellSession extends ShellSession {
|
||||
protected nodeName: string;
|
||||
protected podId: string
|
||||
protected kc: KubeConfig
|
||||
|
||||
constructor(socket: WebSocket, pathToKubeconfig: string, cluster: Cluster, nodeName: string) {
|
||||
super(socket, pathToKubeconfig, cluster)
|
||||
constructor(socket: WebSocket, cluster: Cluster, nodeName: string) {
|
||||
super(socket, cluster)
|
||||
this.nodeName = nodeName
|
||||
this.podId = `node-shell-${uuid()}`
|
||||
this.kc = cluster.proxyKubeconfig()
|
||||
this.kc = cluster.getProxyKubeconfig()
|
||||
}
|
||||
|
||||
public async open() {
|
||||
const shell = await this.kubectl.kubectlPath()
|
||||
const shell = await this.kubectl.getPath()
|
||||
let args = []
|
||||
if (this.createNodeShellPod(this.podId, this.nodeName)) {
|
||||
await this.waitForRunningPod(this.podId).catch((error) => {
|
||||
@@ -107,7 +107,7 @@ export class NodeShellSession extends ShellSession {
|
||||
const watch = new k8s.Watch(kc);
|
||||
|
||||
const req = await watch.watch(`/api/v1/namespaces/kube-system/pods`, {},
|
||||
// callback is called for each received object.
|
||||
// callback is called for each received object.
|
||||
(_type, obj) => {
|
||||
if (obj.metadata.name == podId && obj.status.phase === "Running") {
|
||||
resolve(true)
|
||||
@@ -119,9 +119,13 @@ export class NodeShellSession extends ShellSession {
|
||||
reject(false)
|
||||
}
|
||||
);
|
||||
setTimeout(() => { req.abort(); reject(false); }, 120 * 1000);
|
||||
setTimeout(() => {
|
||||
req.abort();
|
||||
reject(false);
|
||||
}, 120 * 1000);
|
||||
})
|
||||
}
|
||||
|
||||
protected deleteNodeShellPod() {
|
||||
const kc = this.getKubeConfig();
|
||||
const k8sApi = kc.makeApiClient(k8s.CoreV1Api);
|
||||
@@ -129,16 +133,13 @@ export class NodeShellSession extends ShellSession {
|
||||
}
|
||||
}
|
||||
|
||||
export async function open(socket: WebSocket, pathToKubeconfig: string, cluster: Cluster, nodeName?: string): Promise<ShellSession> {
|
||||
return new Promise(async(resolve, reject) => {
|
||||
let shell = null
|
||||
if (nodeName) {
|
||||
shell = new NodeShellSession(socket, pathToKubeconfig, cluster, nodeName)
|
||||
}
|
||||
else {
|
||||
shell = new ShellSession(socket, pathToKubeconfig, cluster)
|
||||
}
|
||||
shell.open()
|
||||
resolve(shell)
|
||||
})
|
||||
export async function openShell(socket: WebSocket, cluster: Cluster, nodeName?: string): Promise<ShellSession> {
|
||||
let shell: ShellSession;
|
||||
if (nodeName) {
|
||||
shell = new NodeShellSession(socket, cluster, nodeName)
|
||||
} else {
|
||||
shell = new ShellSession(socket, cluster);
|
||||
}
|
||||
shell.open()
|
||||
return shell;
|
||||
}
|
||||
+18
-25
@@ -1,29 +1,22 @@
|
||||
import net, { AddressInfo } from "net"
|
||||
import logger from "./logger"
|
||||
import { createServer, AddressInfo } from "net"
|
||||
|
||||
const getNextAvailablePort = () => {
|
||||
logger.debug("getNextAvailablePort() start")
|
||||
const server = createServer()
|
||||
server.unref()
|
||||
return new Promise<number>((resolve, reject) =>
|
||||
server
|
||||
.on('error', (error: any) => reject(error))
|
||||
.on('listening', () => {
|
||||
logger.debug("*** server listening event ***")
|
||||
const _port = (server.address() as AddressInfo).port
|
||||
server.close(() => resolve(_port))
|
||||
})
|
||||
.listen({host: "127.0.0.1", port: 0}))
|
||||
}
|
||||
// todo: check https://github.com/http-party/node-portfinder ?
|
||||
|
||||
export const getFreePort = async () => {
|
||||
logger.debug("getFreePort() start")
|
||||
let freePort: number = null
|
||||
try {
|
||||
freePort = await getNextAvailablePort()
|
||||
logger.debug("got port : " + freePort)
|
||||
} catch(error) {
|
||||
throw("getNextAvailablePort() threw: '" + error + "'")
|
||||
}
|
||||
return freePort
|
||||
export async function getFreePort(): Promise<number> {
|
||||
logger.debug("Lookup new free port..");
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer()
|
||||
server.unref()
|
||||
server.on("listening", () => {
|
||||
const port = (server.address() as AddressInfo).port
|
||||
server.close(() => resolve(port));
|
||||
logger.debug(`New port found: ${port}`);
|
||||
});
|
||||
server.on("error", error => {
|
||||
logger.error(`Can't resolve new port: "${error}"`);
|
||||
reject(error);
|
||||
});
|
||||
server.listen({ host: "127.0.0.1", port: 0 })
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { EventEmitter } from 'events'
|
||||
import { getFreePort } from "./port"
|
||||
|
||||
let newPort = 0;
|
||||
|
||||
jest.mock("net", () => {
|
||||
return {
|
||||
createServer() {
|
||||
@@ -10,7 +12,10 @@ jest.mock("net", () => {
|
||||
return this
|
||||
})
|
||||
address = () => {
|
||||
return { port: 12345 }
|
||||
newPort = Math.round(Math.random() * 10000)
|
||||
return {
|
||||
port: newPort
|
||||
}
|
||||
}
|
||||
unref = jest.fn()
|
||||
close = jest.fn(cb => cb())
|
||||
@@ -21,6 +26,6 @@ jest.mock("net", () => {
|
||||
|
||||
describe("getFreePort", () => {
|
||||
it("finds the next free port", async () => {
|
||||
return expect(getFreePort()).resolves.toEqual(expect.any(Number))
|
||||
return expect(getFreePort()).resolves.toEqual(newPort);
|
||||
})
|
||||
})
|
||||
@@ -1,167 +0,0 @@
|
||||
import http from "http";
|
||||
import httpProxy from "http-proxy";
|
||||
import { Socket } from "net";
|
||||
import * as url from "url";
|
||||
import * as WebSocket from "ws"
|
||||
import { ContextHandler } from "./context-handler";
|
||||
import logger from "./logger"
|
||||
import * as shell from "./node-shell-session"
|
||||
import { ClusterManager } from "./cluster-manager"
|
||||
import { Router } from "./router"
|
||||
import { apiPrefix } from "../common/vars";
|
||||
|
||||
export class LensProxy {
|
||||
public static readonly localShellSessions = true
|
||||
|
||||
public port: number;
|
||||
protected clusterUrl: url.UrlWithStringQuery
|
||||
protected clusterManager: ClusterManager
|
||||
protected retryCounters: Map<string, number> = new Map()
|
||||
protected router: Router
|
||||
protected proxyServer: http.Server
|
||||
protected closed = false
|
||||
|
||||
constructor(port: number, clusterManager: ClusterManager) {
|
||||
this.port = port
|
||||
this.clusterManager = clusterManager
|
||||
this.router = new Router()
|
||||
}
|
||||
|
||||
public run() {
|
||||
const proxyServer = this.buildProxyServer();
|
||||
proxyServer.listen(this.port, "127.0.0.1")
|
||||
this.proxyServer = proxyServer
|
||||
}
|
||||
|
||||
public close() {
|
||||
logger.info("Closing proxy server")
|
||||
this.proxyServer.close()
|
||||
this.closed = true
|
||||
}
|
||||
|
||||
protected buildProxyServer() {
|
||||
const proxy = this.createProxy();
|
||||
const proxyServer = http.createServer((req: http.IncomingMessage, res: http.ServerResponse) => {
|
||||
this.handleRequest(proxy, req, res);
|
||||
});
|
||||
proxyServer.on("upgrade", (req: http.IncomingMessage, socket: Socket, head: Buffer) => {
|
||||
this.handleWsUpgrade(req, socket, head)
|
||||
});
|
||||
proxyServer.on("error", (err) => {
|
||||
logger.error(err)
|
||||
});
|
||||
return proxyServer;
|
||||
}
|
||||
|
||||
protected createProxy() {
|
||||
const proxy = httpProxy.createProxyServer();
|
||||
|
||||
proxy.on("proxyRes", (proxyRes, req, res) => {
|
||||
if (proxyRes.statusCode === 502) {
|
||||
const cluster = this.clusterManager.getClusterForRequest(req)
|
||||
if (cluster && cluster.contextHandler.proxyServerError()) {
|
||||
res.writeHead(proxyRes.statusCode, {
|
||||
"Content-Type": "text/plain"
|
||||
})
|
||||
res.end(cluster.contextHandler.proxyServerError())
|
||||
return
|
||||
}
|
||||
}
|
||||
if (req.method !== "GET") {
|
||||
return
|
||||
}
|
||||
const key = `${req.headers.host}${req.url}`
|
||||
if (this.retryCounters.has(key)) {
|
||||
logger.debug("Resetting proxy retry cache for url: " + key)
|
||||
this.retryCounters.delete(key)
|
||||
}
|
||||
})
|
||||
proxy.on("error", (error, req, res, target) => {
|
||||
if(this.closed) {
|
||||
return
|
||||
}
|
||||
if (target) {
|
||||
logger.debug("Failed proxy to target: " + JSON.stringify(target))
|
||||
if (req.method === "GET" && (!res.statusCode || res.statusCode >= 500)) {
|
||||
const retryCounterKey = `${req.headers.host}${req.url}`
|
||||
const retryCount = this.retryCounters.get(retryCounterKey) || 0
|
||||
if (retryCount < 20) {
|
||||
logger.debug("Retrying proxy request to url: " + retryCounterKey)
|
||||
setTimeout(() => {
|
||||
this.retryCounters.set(retryCounterKey, retryCount + 1)
|
||||
this.handleRequest(proxy, req, res)
|
||||
}, (250 * retryCount))
|
||||
}
|
||||
}
|
||||
}
|
||||
res.writeHead(500, {
|
||||
'Content-Type': 'text/plain'
|
||||
})
|
||||
res.end('Oops, something went wrong.')
|
||||
})
|
||||
|
||||
return proxy;
|
||||
}
|
||||
|
||||
protected createWsListener() {
|
||||
const ws = new WebSocket.Server({ noServer: true })
|
||||
ws.on("connection", ((con: WebSocket, req: http.IncomingMessage) => {
|
||||
const cluster = this.clusterManager.getClusterForRequest(req)
|
||||
const contextHandler = cluster.contextHandler
|
||||
const nodeParam = url.parse(req.url, true).query["node"]?.toString();
|
||||
|
||||
contextHandler.withTemporaryKubeconfig((kubeconfigPath) => {
|
||||
return new Promise<boolean>(async (resolve, reject) => {
|
||||
const shellSession = await shell.open(con, kubeconfigPath, cluster, nodeParam)
|
||||
shellSession.on("exit", () => {
|
||||
resolve(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
}))
|
||||
return ws
|
||||
}
|
||||
|
||||
protected async getProxyTarget(req: http.IncomingMessage, contextHandler: ContextHandler): Promise<httpProxy.ServerOptions> {
|
||||
const prefix = apiPrefix.KUBE_BASE;
|
||||
if (req.url.startsWith(prefix)) {
|
||||
delete req.headers.authorization
|
||||
req.url = req.url.replace(prefix, "")
|
||||
const isWatchRequest = req.url.includes("watch=")
|
||||
return await contextHandler.getApiTarget(isWatchRequest)
|
||||
}
|
||||
}
|
||||
|
||||
protected async handleRequest(proxy: httpProxy, req: http.IncomingMessage, res: http.ServerResponse) {
|
||||
const cluster = this.clusterManager.getClusterForRequest(req)
|
||||
if (!cluster) {
|
||||
logger.error("Got request to unknown cluster")
|
||||
logger.debug(req.headers.host + req.url)
|
||||
res.statusCode = 503
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
const contextHandler = cluster.contextHandler
|
||||
contextHandler.ensureServer().then(async () => {
|
||||
const proxyTarget = await this.getProxyTarget(req, contextHandler)
|
||||
if (proxyTarget) {
|
||||
proxy.web(req, res, proxyTarget)
|
||||
} else {
|
||||
this.router.route(cluster, req, res)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
protected async handleWsUpgrade(req: http.IncomingMessage, socket: Socket, head: Buffer) {
|
||||
const wsServer = this.createWsListener();
|
||||
wsServer.handleUpgrade(req, socket, head, (con) => {
|
||||
wsServer.emit("connection", con, req);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function listen(port: number, clusterManager: ClusterManager) {
|
||||
const proxyServer = new LensProxy(port, clusterManager)
|
||||
proxyServer.run();
|
||||
return proxyServer;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { LensApiRequest } from "./router"
|
||||
import * as resourceApplier from "./resource-applier"
|
||||
import { LensApi } from "./lens-api"
|
||||
|
||||
class ResourceApplierApi extends LensApi {
|
||||
public async applyResource(request: LensApiRequest) {
|
||||
const { response, cluster, payload } = request
|
||||
try {
|
||||
const resource = await resourceApplier.apply(cluster, cluster.proxyKubeconfigPath(), payload)
|
||||
this.respondJson(response, [resource], 200)
|
||||
} catch(error) {
|
||||
this.respondText(response, error, 422)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const resourceApplierApi = new ResourceApplierApi()
|
||||
@@ -1,47 +1,31 @@
|
||||
import type { Cluster } from "./cluster";
|
||||
import { KubernetesObject } from "@kubernetes/client-node"
|
||||
import { exec } from "child_process";
|
||||
import fs from "fs";
|
||||
import * as yaml from "js-yaml";
|
||||
import path from "path";
|
||||
import * as tempy from "tempy";
|
||||
import logger from "./logger"
|
||||
import { Cluster } from "./cluster";
|
||||
import { tracker } from "./tracker";
|
||||
|
||||
type KubeObject = {
|
||||
status: {};
|
||||
metadata?: {
|
||||
resourceVersion: number;
|
||||
annotations?: {
|
||||
"kubectl.kubernetes.io/last-applied-configuration": string;
|
||||
};
|
||||
};
|
||||
}
|
||||
import { tracker } from "../common/tracker";
|
||||
import { cloneJsonObject } from "../common/utils";
|
||||
|
||||
export class ResourceApplier {
|
||||
protected kubeconfigPath: string;
|
||||
protected cluster: Cluster
|
||||
|
||||
constructor(cluster: Cluster, pathToKubeconfig: string) {
|
||||
this.kubeconfigPath = pathToKubeconfig
|
||||
this.cluster = cluster
|
||||
constructor(protected cluster: Cluster) {
|
||||
}
|
||||
|
||||
public async apply(resource: any): Promise<string> {
|
||||
this.sanitizeObject((resource as KubeObject))
|
||||
try {
|
||||
tracker.event("resource", "apply")
|
||||
return await this.kubectlApply(yaml.safeDump(resource))
|
||||
} catch(error) {
|
||||
throw (error)
|
||||
}
|
||||
async apply(resource: KubernetesObject | any): Promise<string> {
|
||||
resource = this.sanitizeObject(resource);
|
||||
tracker.event("resource", "apply")
|
||||
return await this.kubectlApply(yaml.safeDump(resource));
|
||||
}
|
||||
|
||||
protected async kubectlApply(content: string): Promise<string> {
|
||||
const kubectl = await this.cluster.kubeCtl.kubectlPath()
|
||||
const { kubeCtl, kubeConfigPath } = this.cluster;
|
||||
const kubectlPath = await kubeCtl.getPath()
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const fileName = tempy.file({name: "resource.yaml"})
|
||||
const fileName = tempy.file({ name: "resource.yaml" })
|
||||
fs.writeFileSync(fileName, content)
|
||||
const cmd = `"${kubectl}" apply --kubeconfig ${this.kubeconfigPath} -o json -f ${fileName}`
|
||||
const cmd = `"${kubectlPath}" apply --kubeconfig "${kubeConfigPath}" -o json -f "${fileName}"`
|
||||
logger.debug("shooting manifests with: " + cmd);
|
||||
const execEnv: NodeJS.ProcessEnv = Object.assign({}, process.env)
|
||||
const httpsProxy = this.cluster.preferences?.httpsProxy
|
||||
@@ -62,17 +46,18 @@ export class ResourceApplier {
|
||||
}
|
||||
|
||||
public async kubectlApplyAll(resources: string[]): Promise<string> {
|
||||
const kubectl = await this.cluster.kubeCtl.kubectlPath()
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const { kubeCtl, kubeConfigPath } = this.cluster;
|
||||
const kubectlPath = await kubeCtl.getPath()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tmpDir = tempy.directory()
|
||||
// Dump each resource into tmpDir
|
||||
for (const i in resources) {
|
||||
fs.writeFileSync(path.join(tmpDir, `${i}.yaml`), resources[i])
|
||||
}
|
||||
const cmd = `"${kubectl}" apply --kubeconfig ${this.kubeconfigPath} -o json -f ${tmpDir}`
|
||||
resources.forEach((resource, index) => {
|
||||
fs.writeFileSync(path.join(tmpDir, `${index}.yaml`), resource);
|
||||
})
|
||||
const cmd = `"${kubectlPath}" apply --kubeconfig "${kubeConfigPath}" -o json -f "${tmpDir}"`
|
||||
console.log("shooting manifests with:", cmd);
|
||||
exec(cmd, (error, stdout, stderr) => {
|
||||
if(error) {
|
||||
if (error) {
|
||||
reject("Error applying manifests:" + error);
|
||||
}
|
||||
if (stderr != "") {
|
||||
@@ -84,18 +69,14 @@ export class ResourceApplier {
|
||||
})
|
||||
}
|
||||
|
||||
protected sanitizeObject(resource: KubeObject) {
|
||||
delete resource['status']
|
||||
if (resource['metadata']) {
|
||||
if (resource['metadata']['annotations'] && resource['metadata']['annotations']['kubectl.kubernetes.io/last-applied-configuration']) {
|
||||
delete resource['metadata']['annotations']['kubectl.kubernetes.io/last-applied-configuration']
|
||||
}
|
||||
delete resource['metadata']['resourceVersion']
|
||||
protected sanitizeObject(resource: KubernetesObject | any) {
|
||||
resource = cloneJsonObject(resource);
|
||||
delete resource.status;
|
||||
delete resource.metadata?.resourceVersion;
|
||||
const annotations = resource.metadata?.annotations;
|
||||
if (annotations) {
|
||||
delete annotations['kubectl.kubernetes.io/last-applied-configuration'];
|
||||
}
|
||||
return resource;
|
||||
}
|
||||
}
|
||||
|
||||
export async function apply(cluster: Cluster, pathToKubeconfig: string, resource: any) {
|
||||
const resourceApplier = new ResourceApplier(cluster, pathToKubeconfig)
|
||||
return await resourceApplier.apply(resource)
|
||||
}
|
||||
+76
-78
@@ -4,71 +4,68 @@ import http from "http"
|
||||
import path from "path"
|
||||
import { readFile } from "fs-extra"
|
||||
import { Cluster } from "./cluster"
|
||||
import { configRoute } from "./routes/config"
|
||||
import { helmApi } from "./helm-api"
|
||||
import { resourceApplierApi } from "./resource-applier-api"
|
||||
import { kubeconfigRoute } from "./routes/kubeconfig"
|
||||
import { metricsRoute } from "./routes/metrics"
|
||||
import { watchRoute } from "./routes/watch"
|
||||
import { portForwardRoute } from "./routes/port-forward"
|
||||
import { apiPrefix, outDir, reactAppName } from "../common/vars";
|
||||
import { apiPrefix, appName, publicPath } from "../common/vars";
|
||||
import { helmRoute, kubeconfigRoute, metricsRoute, portForwardRoute, resourceApplierRoute, watchRoute } from "./routes";
|
||||
|
||||
const mimeTypes: Record<string, string> = {
|
||||
"html": "text/html",
|
||||
"txt": "text/plain",
|
||||
"css": "text/css",
|
||||
"gif": "image/gif",
|
||||
"jpg": "image/jpeg",
|
||||
"png": "image/png",
|
||||
"svg": "image/svg+xml",
|
||||
"js": "application/javascript",
|
||||
"woff2": "font/woff2",
|
||||
"ttf": "font/ttf"
|
||||
};
|
||||
|
||||
interface RouteParams {
|
||||
[key: string]: string | undefined;
|
||||
export interface RouterRequestOpts {
|
||||
req: http.IncomingMessage;
|
||||
res: http.ServerResponse;
|
||||
cluster: Cluster;
|
||||
params: RouteParams;
|
||||
url: URL;
|
||||
}
|
||||
|
||||
export type LensApiRequest = {
|
||||
cluster: Cluster;
|
||||
payload: any;
|
||||
raw: {
|
||||
req: http.IncomingMessage;
|
||||
};
|
||||
export interface RouteParams extends Record<string, string> {
|
||||
path?: string; // *-route
|
||||
namespace?: string;
|
||||
service?: string;
|
||||
account?: string;
|
||||
release?: string;
|
||||
repo?: string;
|
||||
chart?: string;
|
||||
}
|
||||
|
||||
export interface LensApiRequest<P = any> {
|
||||
path: string;
|
||||
payload: P;
|
||||
params: RouteParams;
|
||||
cluster: Cluster;
|
||||
response: http.ServerResponse;
|
||||
query: URLSearchParams;
|
||||
path: string;
|
||||
raw: {
|
||||
req: http.IncomingMessage;
|
||||
}
|
||||
}
|
||||
|
||||
export class Router {
|
||||
protected router: any
|
||||
protected router: any;
|
||||
|
||||
public constructor() {
|
||||
this.router = new Call.Router();
|
||||
this.addRoutes()
|
||||
}
|
||||
|
||||
public async route(cluster: Cluster, req: http.IncomingMessage, res: http.ServerResponse) {
|
||||
const url = new URL(req.url, "http://localhost")
|
||||
public async route(cluster: Cluster, req: http.IncomingMessage, res: http.ServerResponse): Promise<boolean> {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
const path = url.pathname
|
||||
const method = req.method.toLowerCase()
|
||||
const matchingRoute = this.router.route(method, path)
|
||||
|
||||
if (matchingRoute.isBoom !== true) { // route() returns error if route not found -> object.isBoom === true
|
||||
const request = await this.getRequest({ req, res, cluster, url, params: matchingRoute.params })
|
||||
const matchingRoute = this.router.route(method, path);
|
||||
const routeFound = !matchingRoute.isBoom;
|
||||
if (routeFound) {
|
||||
const request = await this.getRequest({ req, res, cluster, url, params: matchingRoute.params });
|
||||
await matchingRoute.route(request)
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
protected async getRequest(opts: { req: http.IncomingMessage; res: http.ServerResponse; cluster: Cluster; url: URL; params: RouteParams }) {
|
||||
protected async getRequest(opts: RouterRequestOpts): Promise<LensApiRequest> {
|
||||
const { req, res, url, cluster, params } = opts
|
||||
const { payload } = await Subtext.parse(req, null, { parse: true, output: 'data' });
|
||||
const request: LensApiRequest = {
|
||||
const { payload } = await Subtext.parse(req, null, {
|
||||
parse: true,
|
||||
output: "data",
|
||||
});
|
||||
return {
|
||||
cluster: cluster,
|
||||
path: url.pathname,
|
||||
raw: {
|
||||
@@ -79,67 +76,68 @@ export class Router {
|
||||
payload: payload,
|
||||
params: params
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
protected getMimeType(filename: string) {
|
||||
const mimeTypes: Record<string, string> = {
|
||||
html: "text/html",
|
||||
txt: "text/plain",
|
||||
css: "text/css",
|
||||
gif: "image/gif",
|
||||
jpg: "image/jpeg",
|
||||
png: "image/png",
|
||||
svg: "image/svg+xml",
|
||||
js: "application/javascript",
|
||||
woff2: "font/woff2",
|
||||
ttf: "font/ttf"
|
||||
};
|
||||
return mimeTypes[path.extname(filename).slice(1)] || "text/plain"
|
||||
}
|
||||
|
||||
protected async handleStaticFile(filePath: string, response: http.ServerResponse) {
|
||||
const asset = path.resolve(outDir, filePath);
|
||||
async handleStaticFile(filePath: string, res: http.ServerResponse) {
|
||||
const asset = path.join(__static, filePath);
|
||||
try {
|
||||
const data = await readFile(asset);
|
||||
response.setHeader("Content-Type", this.getMimeType(asset));
|
||||
response.write(data)
|
||||
response.end()
|
||||
res.setHeader("Content-Type", this.getMimeType(asset));
|
||||
res.write(data)
|
||||
res.end()
|
||||
} catch (err) {
|
||||
// default to index.html so that react routes work when page is refreshed
|
||||
this.handleStaticFile(`${reactAppName}.html`, response)
|
||||
this.handleStaticFile(`${publicPath}/${appName}.html`, res);
|
||||
}
|
||||
}
|
||||
|
||||
protected addRoutes() {
|
||||
const {
|
||||
BASE: apiBase,
|
||||
KUBE_HELM: apiHelm,
|
||||
KUBE_RESOURCE_APPLIER: apiResource,
|
||||
} = apiPrefix;
|
||||
|
||||
// Static assets
|
||||
this.router.add({ method: 'get', path: '/{path*}' }, (request: LensApiRequest) => {
|
||||
const { response, params } = request
|
||||
const file = params.path || "/index.html"
|
||||
this.handleStaticFile(file, response)
|
||||
})
|
||||
this.router.add({ method: 'get', path: '/{path*}' }, ({ params, response }: LensApiRequest) => {
|
||||
this.handleStaticFile(params.path, response);
|
||||
});
|
||||
|
||||
this.router.add({ method: "get", path: `${apiBase}/config` }, configRoute.routeConfig.bind(configRoute))
|
||||
this.router.add({ method: "get", path: `${apiBase}/kubeconfig/service-account/{namespace}/{account}` }, kubeconfigRoute.routeServiceAccountRoute.bind(kubeconfigRoute))
|
||||
this.router.add({ method: "get", path: `${apiPrefix}/kubeconfig/service-account/{namespace}/{account}` }, kubeconfigRoute.routeServiceAccountRoute.bind(kubeconfigRoute))
|
||||
|
||||
// Watch API
|
||||
this.router.add({ method: "get", path: `${apiBase}/watch` }, watchRoute.routeWatch.bind(watchRoute))
|
||||
this.router.add({ method: "get", path: `${apiPrefix}/watch` }, watchRoute.routeWatch.bind(watchRoute))
|
||||
|
||||
// Metrics API
|
||||
this.router.add({ method: "post", path: `${apiBase}/metrics` }, metricsRoute.routeMetrics.bind(metricsRoute))
|
||||
this.router.add({ method: "post", path: `${apiPrefix}/metrics` }, metricsRoute.routeMetrics.bind(metricsRoute))
|
||||
|
||||
// Port-forward API
|
||||
this.router.add({ method: "post", path: `${apiBase}/pods/{namespace}/{resourceType}/{resourceName}/port-forward/{port}` }, portForwardRoute.routePortForward.bind(portForwardRoute))
|
||||
this.router.add({ method: "post", path: `${apiPrefix}/pods/{namespace}/{resourceType}/{resourceName}/port-forward/{port}` }, portForwardRoute.routePortForward.bind(portForwardRoute))
|
||||
|
||||
// Helm API
|
||||
this.router.add({ method: "get", path: `${apiHelm}/v2/charts` }, helmApi.listCharts.bind(helmApi))
|
||||
this.router.add({ method: "get", path: `${apiHelm}/v2/charts/{repo}/{chart}` }, helmApi.getChart.bind(helmApi))
|
||||
this.router.add({ method: "get", path: `${apiHelm}/v2/charts/{repo}/{chart}/values` }, helmApi.getChartValues.bind(helmApi))
|
||||
this.router.add({ method: "get", path: `${apiPrefix}/v2/charts` }, helmRoute.listCharts.bind(helmRoute))
|
||||
this.router.add({ method: "get", path: `${apiPrefix}/v2/charts/{repo}/{chart}` }, helmRoute.getChart.bind(helmRoute))
|
||||
this.router.add({ method: "get", path: `${apiPrefix}/v2/charts/{repo}/{chart}/values` }, helmRoute.getChartValues.bind(helmRoute))
|
||||
|
||||
this.router.add({ method: "post", path: `${apiHelm}/v2/releases` }, helmApi.installChart.bind(helmApi))
|
||||
this.router.add({ method: `put`, path: `${apiHelm}/v2/releases/{namespace}/{release}` }, helmApi.updateRelease.bind(helmApi))
|
||||
this.router.add({ method: `put`, path: `${apiHelm}/v2/releases/{namespace}/{release}/rollback` }, helmApi.rollbackRelease.bind(helmApi))
|
||||
this.router.add({ method: "get", path: `${apiHelm}/v2/releases/{namespace?}` }, helmApi.listReleases.bind(helmApi))
|
||||
this.router.add({ method: "get", path: `${apiHelm}/v2/releases/{namespace}/{release}` }, helmApi.getRelease.bind(helmApi))
|
||||
this.router.add({ method: "get", path: `${apiHelm}/v2/releases/{namespace}/{release}/values` }, helmApi.getReleaseValues.bind(helmApi))
|
||||
this.router.add({ method: "get", path: `${apiHelm}/v2/releases/{namespace}/{release}/history` }, helmApi.getReleaseHistory.bind(helmApi))
|
||||
this.router.add({ method: "delete", path: `${apiHelm}/v2/releases/{namespace}/{release}` }, helmApi.deleteRelease.bind(helmApi))
|
||||
this.router.add({ method: "post", path: `${apiPrefix}/v2/releases` }, helmRoute.installChart.bind(helmRoute))
|
||||
this.router.add({ method: `put`, path: `${apiPrefix}/v2/releases/{namespace}/{release}` }, helmRoute.updateRelease.bind(helmRoute))
|
||||
this.router.add({ method: `put`, path: `${apiPrefix}/v2/releases/{namespace}/{release}/rollback` }, helmRoute.rollbackRelease.bind(helmRoute))
|
||||
this.router.add({ method: "get", path: `${apiPrefix}/v2/releases/{namespace?}` }, helmRoute.listReleases.bind(helmRoute))
|
||||
this.router.add({ method: "get", path: `${apiPrefix}/v2/releases/{namespace}/{release}` }, helmRoute.getRelease.bind(helmRoute))
|
||||
this.router.add({ method: "get", path: `${apiPrefix}/v2/releases/{namespace}/{release}/values` }, helmRoute.getReleaseValues.bind(helmRoute))
|
||||
this.router.add({ method: "get", path: `${apiPrefix}/v2/releases/{namespace}/{release}/history` }, helmRoute.getReleaseHistory.bind(helmRoute))
|
||||
this.router.add({ method: "delete", path: `${apiPrefix}/v2/releases/{namespace}/{release}` }, helmRoute.deleteRelease.bind(helmRoute))
|
||||
|
||||
// Resource Applier API
|
||||
this.router.add({ method: "post", path: `${apiResource}/stack` }, resourceApplierApi.applyResource.bind(resourceApplierApi))
|
||||
this.router.add({ method: "post", path: `${apiPrefix}/stack` }, resourceApplierRoute.applyResource.bind(resourceApplierRoute))
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import { app } from "electron"
|
||||
import { CoreV1Api } from "@kubernetes/client-node"
|
||||
import { LensApiRequest } from "../router"
|
||||
import { LensApi } from "../lens-api"
|
||||
import { userStore } from "../../common/user-store"
|
||||
import { Cluster } from "../cluster"
|
||||
|
||||
export interface IConfigRoutePayload {
|
||||
kubeVersion?: string;
|
||||
clusterName?: string;
|
||||
lensVersion?: string;
|
||||
lensTheme?: string;
|
||||
username?: string;
|
||||
token?: string;
|
||||
allowedNamespaces?: string[];
|
||||
allowedResources?: string[];
|
||||
isClusterAdmin?: boolean;
|
||||
chartsEnabled: boolean;
|
||||
kubectlAccess?: boolean; // User accessed via kubectl-lens plugin
|
||||
}
|
||||
|
||||
// TODO: auto-populate all resources dynamically
|
||||
const apiResources = [
|
||||
{ resource: "configmaps" },
|
||||
{ resource: "cronjobs", group: "batch" },
|
||||
{ resource: "customresourcedefinitions", group: "apiextensions.k8s.io" },
|
||||
{ resource: "daemonsets", group: "apps" },
|
||||
{ resource: "deployments", group: "apps" },
|
||||
{ resource: "endpoints" },
|
||||
{ resource: "events" },
|
||||
{ resource: "horizontalpodautoscalers" },
|
||||
{ resource: "ingresses", group: "networking.k8s.io" },
|
||||
{ resource: "jobs", group: "batch" },
|
||||
{ resource: "namespaces" },
|
||||
{ resource: "networkpolicies", group: "networking.k8s.io" },
|
||||
{ resource: "nodes" },
|
||||
{ resource: "persistentvolumes" },
|
||||
{ resource: "pods" },
|
||||
{ resource: "podsecuritypolicies" },
|
||||
{ resource: "resourcequotas" },
|
||||
{ resource: "secrets" },
|
||||
{ resource: "services" },
|
||||
{ resource: "statefulsets", group: "apps" },
|
||||
{ resource: "storageclasses", group: "storage.k8s.io" },
|
||||
]
|
||||
|
||||
async function getAllowedNamespaces(cluster: Cluster) {
|
||||
const api = cluster.proxyKubeconfig().makeApiClient(CoreV1Api)
|
||||
try {
|
||||
const namespaceList = await api.listNamespace()
|
||||
const nsAccessStatuses = await Promise.all(
|
||||
namespaceList.body.items.map(ns => cluster.canI({
|
||||
namespace: ns.metadata.name,
|
||||
resource: "pods",
|
||||
verb: "list",
|
||||
}))
|
||||
)
|
||||
return namespaceList.body.items
|
||||
.filter((ns, i) => nsAccessStatuses[i])
|
||||
.map(ns => ns.metadata.name)
|
||||
} catch(error) {
|
||||
const ctx = cluster.proxyKubeconfig().getContextObject(cluster.contextName)
|
||||
if (ctx.namespace) {
|
||||
return [ctx.namespace]
|
||||
}
|
||||
else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getAllowedResources(cluster: Cluster, namespaces: string[]) {
|
||||
try {
|
||||
const resourceAccessStatuses = await Promise.all(
|
||||
apiResources.map(apiResource => cluster.canI({
|
||||
resource: apiResource.resource,
|
||||
group: apiResource.group,
|
||||
verb: "list",
|
||||
namespace: namespaces[0]
|
||||
}))
|
||||
)
|
||||
return apiResources
|
||||
.filter((resource, i) => resourceAccessStatuses[i]).map(apiResource => apiResource.resource)
|
||||
} catch (error) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
class ConfigRoute extends LensApi {
|
||||
public async routeConfig(request: LensApiRequest) {
|
||||
const { params, response, cluster } = request
|
||||
|
||||
const namespaces = await getAllowedNamespaces(cluster)
|
||||
const data: IConfigRoutePayload = {
|
||||
clusterName: cluster.contextName,
|
||||
lensVersion: app.getVersion(),
|
||||
lensTheme: `kontena-${userStore.getPreferences().colorTheme}`,
|
||||
kubeVersion: cluster.version,
|
||||
chartsEnabled: true,
|
||||
isClusterAdmin: cluster.isAdmin,
|
||||
allowedResources: await getAllowedResources(cluster, namespaces),
|
||||
allowedNamespaces: namespaces
|
||||
};
|
||||
|
||||
this.respondJson(response, data)
|
||||
}
|
||||
}
|
||||
|
||||
export const configRoute = new ConfigRoute()
|
||||
@@ -1,9 +1,9 @@
|
||||
import { LensApiRequest } from "./router"
|
||||
import { helmService } from "./helm-service"
|
||||
import { LensApi } from "./lens-api"
|
||||
import logger from "./logger"
|
||||
import { LensApiRequest } from "../router"
|
||||
import { helmService } from "../helm/helm-service"
|
||||
import { LensApi } from "../lens-api"
|
||||
import logger from "../logger"
|
||||
|
||||
class HelmApi extends LensApi {
|
||||
class HelmApiRoute extends LensApi {
|
||||
public async listCharts(request: LensApiRequest) {
|
||||
const { response } = request
|
||||
const charts = await helmService.listCharts()
|
||||
@@ -111,4 +111,4 @@ class HelmApi extends LensApi {
|
||||
}
|
||||
}
|
||||
|
||||
export const helmApi = new HelmApi()
|
||||
export const helmRoute = new HelmApiRoute()
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./kubeconfig-route"
|
||||
export * from "./metrics-route"
|
||||
export * from "./port-forward-route"
|
||||
export * from "./watch-route"
|
||||
export * from "./helm-route"
|
||||
export * from "./resource-applier-route"
|
||||
@@ -4,7 +4,7 @@ import { Cluster } from "../cluster"
|
||||
import { CoreV1Api, V1Secret } from "@kubernetes/client-node"
|
||||
|
||||
function generateKubeConfig(username: string, secret: V1Secret, cluster: Cluster) {
|
||||
const tokenData = new Buffer(secret.data["token"], "base64")
|
||||
const tokenData = Buffer.from(secret.data["token"], "base64")
|
||||
return {
|
||||
'apiVersion': 'v1',
|
||||
'kind': 'Config',
|
||||
@@ -44,7 +44,7 @@ class KubeconfigRoute extends LensApi {
|
||||
public async routeServiceAccountRoute(request: LensApiRequest) {
|
||||
const { params, response, cluster} = request
|
||||
|
||||
const client = cluster.proxyKubeconfig().makeApiClient(CoreV1Api);
|
||||
const client = cluster.getProxyKubeconfig().makeApiClient(CoreV1Api);
|
||||
const secretList = await client.listNamespacedSecret(params.namespace)
|
||||
const secret = secretList.body.items.find(secret => {
|
||||
const { annotations } = secret.metadata;
|
||||
@@ -1,34 +1,25 @@
|
||||
import { LensApiRequest } from "../router"
|
||||
import { LensApi } from "../lens-api"
|
||||
import requestPromise from "request-promise-native"
|
||||
import { PrometheusProviderRegistry, PrometheusProvider, PrometheusNodeQuery, PrometheusClusterQuery, PrometheusPodQuery, PrometheusPvcQuery, PrometheusIngressQuery, PrometheusQueryOpts} from "../prometheus/provider-registry"
|
||||
import { apiPrefix } from "../../common/vars";
|
||||
import { PrometheusClusterQuery, PrometheusIngressQuery, PrometheusNodeQuery, PrometheusPodQuery, PrometheusProvider, PrometheusPvcQuery, PrometheusQueryOpts } from "../prometheus/provider-registry"
|
||||
|
||||
export type IMetricsQuery = string | string[] | {
|
||||
[metricName: string]: string;
|
||||
}
|
||||
|
||||
class MetricsRoute extends LensApi {
|
||||
|
||||
public async routeMetrics(request: LensApiRequest) {
|
||||
const { response, cluster} = request
|
||||
const query: IMetricsQuery = request.payload;
|
||||
const serverUrl = `http://127.0.0.1:${cluster.port}${apiPrefix.KUBE_BASE}`
|
||||
const headers = {
|
||||
"Host": `${cluster.id}.localhost:${cluster.port}`,
|
||||
"Content-type": "application/json",
|
||||
}
|
||||
async routeMetrics(request: LensApiRequest) {
|
||||
const { response, cluster, payload } = request
|
||||
const queryParams: IMetricsQuery = {}
|
||||
request.query.forEach((value: string, key: string) => {
|
||||
queryParams[key] = value
|
||||
})
|
||||
|
||||
let metricsUrl: string
|
||||
let prometheusPath: string
|
||||
let prometheusProvider: PrometheusProvider
|
||||
try {
|
||||
const prometheusPath = await cluster.contextHandler.getPrometheusPath()
|
||||
metricsUrl = `${serverUrl}/api/v1/namespaces/${prometheusPath}/proxy${cluster.getPrometheusApiPrefix()}/api/v1/query_range`
|
||||
prometheusProvider = await cluster.contextHandler.getPrometheusProvider()
|
||||
[prometheusPath, prometheusProvider] = await Promise.all([
|
||||
cluster.contextHandler.getPrometheusPath(),
|
||||
cluster.contextHandler.getPrometheusProvider()
|
||||
])
|
||||
} catch {
|
||||
this.respondJson(response, {})
|
||||
return
|
||||
@@ -36,18 +27,10 @@ class MetricsRoute extends LensApi {
|
||||
// prometheus metrics loader
|
||||
const attempts: { [query: string]: number } = {};
|
||||
const maxAttempts = 5;
|
||||
const loadMetrics = (orgQuery: string): Promise<any> => {
|
||||
const query = orgQuery.trim()
|
||||
const loadMetrics = (promQuery: string): Promise<any> => {
|
||||
const query = promQuery.trim()
|
||||
const attempt = attempts[query] = (attempts[query] || 0) + 1;
|
||||
return requestPromise(metricsUrl, {
|
||||
resolveWithFullResponse: false,
|
||||
headers: headers,
|
||||
json: true,
|
||||
qs: {
|
||||
query: query,
|
||||
...queryParams
|
||||
}
|
||||
}).catch(async (error) => {
|
||||
return cluster.getMetrics(prometheusPath, { query, ...queryParams }).catch(async error => {
|
||||
if (attempt < maxAttempts && (error.statusCode && error.statusCode != 404)) {
|
||||
await new Promise(resolve => setTimeout(resolve, attempt * 1000)); // add delay before repeating request
|
||||
return loadMetrics(query);
|
||||
@@ -63,16 +46,14 @@ class MetricsRoute extends LensApi {
|
||||
|
||||
// return data in same structure as query
|
||||
let data: any;
|
||||
if (typeof query === "string") {
|
||||
data = await loadMetrics(query)
|
||||
}
|
||||
else if (Array.isArray(query)) {
|
||||
data = await Promise.all(query.map(loadMetrics));
|
||||
}
|
||||
else {
|
||||
if (typeof payload === "string") {
|
||||
data = await loadMetrics(payload)
|
||||
} else if (Array.isArray(payload)) {
|
||||
data = await Promise.all(payload.map(loadMetrics));
|
||||
} else {
|
||||
data = {};
|
||||
const result = await Promise.all(
|
||||
Object.entries(query).map((queryEntry: any) => {
|
||||
Object.entries(payload).map((queryEntry: any) => {
|
||||
const queryName: string = queryEntry[0]
|
||||
const queryOpts: PrometheusQueryOpts = queryEntry[1]
|
||||
const queries = prometheusProvider.getQueries(queryOpts)
|
||||
@@ -80,7 +61,7 @@ class MetricsRoute extends LensApi {
|
||||
return loadMetrics(q)
|
||||
})
|
||||
);
|
||||
Object.keys(query).forEach((metricName, index) => {
|
||||
Object.keys(payload).forEach((metricName, index) => {
|
||||
data[metricName] = result[index];
|
||||
});
|
||||
}
|
||||
@@ -37,7 +37,7 @@ class PortForward {
|
||||
|
||||
public async start() {
|
||||
this.localPort = await getFreePort()
|
||||
const kubectlBin = await bundledKubectl.kubectlPath()
|
||||
const kubectlBin = await bundledKubectl.getPath()
|
||||
const args = [
|
||||
"--kubeconfig", this.kubeConfig,
|
||||
"port-forward",
|
||||
@@ -88,7 +88,7 @@ class PortForwardRoute extends LensApi {
|
||||
namespace: namespace,
|
||||
name: resourceName,
|
||||
port: port,
|
||||
kubeConfig: cluster.proxyKubeconfigPath()
|
||||
kubeConfig: cluster.getProxyKubeconfigPath()
|
||||
})
|
||||
const started = await portForward.start()
|
||||
if (!started) {
|
||||
@@ -0,0 +1,17 @@
|
||||
import { LensApiRequest } from "../router"
|
||||
import { LensApi } from "../lens-api"
|
||||
import { ResourceApplier } from "../resource-applier"
|
||||
|
||||
class ResourceApplierApiRoute extends LensApi {
|
||||
public async applyResource(request: LensApiRequest) {
|
||||
const { response, cluster, payload } = request
|
||||
try {
|
||||
const resource = await new ResourceApplier(cluster).apply(payload);
|
||||
this.respondJson(response, [resource], 200)
|
||||
} catch (error) {
|
||||
this.respondText(response, error, 422)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const resourceApplierRoute = new ResourceApplierApiRoute()
|
||||
@@ -87,10 +87,10 @@ class WatchRoute extends LensApi {
|
||||
response.setHeader("Content-Type", "text/event-stream")
|
||||
response.setHeader("Cache-Control", "no-cache")
|
||||
response.setHeader("Connection", "keep-alive")
|
||||
logger.debug("watch using kubeconfig:" + JSON.stringify(cluster.proxyKubeconfig(), null, 2))
|
||||
logger.debug("watch using kubeconfig:" + JSON.stringify(cluster.getProxyKubeconfig(), null, 2))
|
||||
|
||||
apis.forEach(apiUrl => {
|
||||
const watcher = new ApiWatcher(apiUrl, cluster.proxyKubeconfig(), response)
|
||||
const watcher = new ApiWatcher(apiUrl, cluster.getProxyKubeconfig(), response)
|
||||
watcher.start()
|
||||
watchers.push(watcher)
|
||||
})
|
||||
@@ -5,10 +5,11 @@ import path from "path"
|
||||
import shellEnv from "shell-env"
|
||||
import { app } from "electron"
|
||||
import { Kubectl } from "./kubectl"
|
||||
import { tracker } from "./tracker"
|
||||
import { Cluster, ClusterPreferences } from "./cluster"
|
||||
import { helmCli } from "./helm-cli"
|
||||
import { Cluster } from "./cluster"
|
||||
import { ClusterPreferences } from "../common/cluster-store";
|
||||
import { helmCli } from "./helm/helm-cli"
|
||||
import { isWindows } from "../common/vars";
|
||||
import { tracker } from "../common/tracker";
|
||||
|
||||
export class ShellSession extends EventEmitter {
|
||||
static shellEnvs: Map<string, any> = new Map()
|
||||
@@ -24,10 +25,10 @@ export class ShellSession extends EventEmitter {
|
||||
protected running = false;
|
||||
protected clusterId: string;
|
||||
|
||||
constructor(socket: WebSocket, pathToKubeconfig: string, cluster: Cluster) {
|
||||
constructor(socket: WebSocket, cluster: Cluster) {
|
||||
super()
|
||||
this.websocket = socket
|
||||
this.kubeconfigPath = pathToKubeconfig
|
||||
this.kubeconfigPath = cluster.kubeConfigPath
|
||||
this.kubectl = new Kubectl(cluster.version)
|
||||
this.preferences = cluster.preferences || {}
|
||||
this.clusterId = cluster.id
|
||||
|
||||
+13
-4
@@ -1,5 +1,7 @@
|
||||
import shellEnv from "shell-env"
|
||||
import os from "os";
|
||||
import { app } from "electron";
|
||||
import logger from "./logger";
|
||||
|
||||
interface Env {
|
||||
[key: string]: string;
|
||||
@@ -9,14 +11,21 @@ interface Env {
|
||||
* shellSync loads what would have been the environment if this application was
|
||||
* run from the command line, into the process.env object. This is especially
|
||||
* useful on macos where this always needs to be done.
|
||||
* @param locale Should be electron's `app.getLocale()`
|
||||
*/
|
||||
export function shellSync(locale: string) {
|
||||
export async function shellSync() {
|
||||
const { shell } = os.userInfo();
|
||||
const env: Env = JSON.parse(JSON.stringify(shellEnv.sync(shell)))
|
||||
|
||||
let envVars = {};
|
||||
try {
|
||||
envVars = await shellEnv(shell);
|
||||
} catch (error) {
|
||||
logger.error(`shellEnv: ${error}`)
|
||||
}
|
||||
|
||||
const env: Env = JSON.parse(JSON.stringify(envVars));
|
||||
if (!env.LANG) {
|
||||
// the LANG env var expects an underscore instead of electron's dash
|
||||
env.LANG = `${locale.replace('-', '_')}.UTF-8`;
|
||||
env.LANG = `${app.getLocale().replace('-', '_')}.UTF-8`;
|
||||
} else if (!env.LANG.endsWith(".UTF-8")) {
|
||||
env.LANG += ".UTF-8"
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import { Tracker } from "../common/tracker"
|
||||
import { app, remote } from "electron"
|
||||
|
||||
export const tracker = new Tracker(app || remote.app);
|
||||
@@ -1,7 +0,0 @@
|
||||
import { webContents } from "electron"
|
||||
/**
|
||||
* Helper to find the correct web contents handle for main window
|
||||
*/
|
||||
export function findMainWebContents() {
|
||||
return webContents.getAllWebContents().find(w => w.getType() === "window");
|
||||
}
|
||||
+69
-62
@@ -1,89 +1,96 @@
|
||||
import { BrowserWindow, shell } from "electron"
|
||||
import { PromiseIpc } from "electron-promise-ipc"
|
||||
import type { ClusterId } from "../common/cluster-store";
|
||||
import { BrowserWindow, dialog, ipcMain, shell, WebContents, webContents } from "electron"
|
||||
import windowStateKeeper from "electron-window-state"
|
||||
import { tracker } from "./tracker";
|
||||
import { getStaticUrl } from "../common/register-static";
|
||||
import { observable } from "mobx";
|
||||
import { initMenu } from "./menu";
|
||||
|
||||
export class WindowManager {
|
||||
public mainWindow: BrowserWindow = null;
|
||||
public splashWindow: BrowserWindow = null;
|
||||
protected promiseIpc: any
|
||||
protected mainView: BrowserWindow;
|
||||
protected splashWindow: BrowserWindow;
|
||||
protected windowState: windowStateKeeper.State;
|
||||
|
||||
constructor({ showSplash = true } = {}) {
|
||||
this.promiseIpc = new PromiseIpc({ timeout: 2000 })
|
||||
// Manage main window size&position with persistence
|
||||
@observable activeClusterId: ClusterId;
|
||||
|
||||
constructor(protected proxyPort: number) {
|
||||
// Manage main window size and position with state persistence
|
||||
this.windowState = windowStateKeeper({
|
||||
defaultHeight: 900,
|
||||
defaultWidth: 1440,
|
||||
});
|
||||
|
||||
this.splashWindow = new BrowserWindow({
|
||||
width: 500,
|
||||
height: 300,
|
||||
backgroundColor: "#1e2124",
|
||||
center: true,
|
||||
frame: false,
|
||||
resizable: false,
|
||||
const { width, height, x, y } = this.windowState;
|
||||
this.mainView = new BrowserWindow({
|
||||
x, y, width, height,
|
||||
show: false,
|
||||
webPreferences: {
|
||||
nodeIntegration: true
|
||||
}
|
||||
})
|
||||
if (showSplash) {
|
||||
this.splashWindow.loadURL(getStaticUrl("splash.html"))
|
||||
this.splashWindow.show()
|
||||
}
|
||||
|
||||
this.mainWindow = new BrowserWindow({
|
||||
show: false,
|
||||
x: this.windowState.x,
|
||||
y: this.windowState.y,
|
||||
width: this.windowState.width,
|
||||
height: this.windowState.height,
|
||||
backgroundColor: "#1e2124",
|
||||
minWidth: 900,
|
||||
minHeight: 760,
|
||||
titleBarStyle: "hidden",
|
||||
backgroundColor: "#1e2124",
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
webviewTag: true
|
||||
nodeIntegrationInSubFrames: true,
|
||||
enableRemoteModule: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Hook window state manager into window lifecycle
|
||||
this.windowState.manage(this.mainWindow);
|
||||
|
||||
// handle close event
|
||||
this.mainWindow.on("close", () => {
|
||||
this.mainWindow = null;
|
||||
});
|
||||
this.windowState.manage(this.mainView);
|
||||
|
||||
// open external links in default browser (target=_blank, window.open)
|
||||
this.mainWindow.webContents.on("new-window", (event, url) => {
|
||||
this.mainView.webContents.on("new-window", (event, url) => {
|
||||
event.preventDefault();
|
||||
shell.openExternal(url);
|
||||
});
|
||||
|
||||
// handle external links
|
||||
this.mainWindow.webContents.on("will-navigate", (event, link) => {
|
||||
if (link.startsWith("http://localhost")) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
shell.openExternal(link);
|
||||
})
|
||||
// track visible cluster from ui
|
||||
ipcMain.on("cluster-view:change", (event, clusterId: ClusterId) => {
|
||||
this.activeClusterId = clusterId;
|
||||
});
|
||||
|
||||
this.mainWindow.on("focus", () => {
|
||||
tracker.event("app", "focus")
|
||||
})
|
||||
// load & show app
|
||||
this.showMain();
|
||||
initMenu(this);
|
||||
}
|
||||
|
||||
public showMain(url: string) {
|
||||
this.mainWindow.loadURL(url).then(() => {
|
||||
this.splashWindow.hide()
|
||||
this.splashWindow.loadURL("data:text/html;charset=utf-8,").then(() => {
|
||||
this.splashWindow.close()
|
||||
this.mainWindow.show()
|
||||
})
|
||||
})
|
||||
navigate({ url, channel, frameId }: { url: string, channel: string, frameId?: number }) {
|
||||
if (frameId) {
|
||||
this.mainView.webContents.sendToFrame(frameId, channel, url);
|
||||
} else {
|
||||
this.mainView.webContents.send(channel, url);
|
||||
}
|
||||
}
|
||||
|
||||
async showMain() {
|
||||
try {
|
||||
await this.showSplash();
|
||||
await this.mainView.loadURL(`http://localhost:${this.proxyPort}`)
|
||||
this.mainView.show();
|
||||
this.splashWindow.close();
|
||||
} catch (err) {
|
||||
dialog.showErrorBox("ERROR!", err.toString())
|
||||
}
|
||||
}
|
||||
|
||||
async showSplash() {
|
||||
if (!this.splashWindow) {
|
||||
this.splashWindow = new BrowserWindow({
|
||||
width: 500,
|
||||
height: 300,
|
||||
backgroundColor: "#1e2124",
|
||||
center: true,
|
||||
frame: false,
|
||||
resizable: false,
|
||||
show: false,
|
||||
webPreferences: {
|
||||
nodeIntegration: true
|
||||
}
|
||||
});
|
||||
await this.splashWindow.loadURL("static://splash.html");
|
||||
}
|
||||
this.splashWindow.show();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.windowState.unmanage();
|
||||
this.splashWindow.destroy();
|
||||
this.mainView.destroy();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user