project restructuring and initial refactoring

This commit is contained in:
Roman committed 2020-06-09 15:14:40 +03:00
1 parent 5af12bdbbd
commit aa0eca2dc4
807 files changed
+11848 -22947

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
import { autoUpdater } from "electron-updater"
import logger from "./logger"
export default class AppUpdater {
protected updateInterval: number = (1000 * 60 * 60 * 24) // once a day
constructor() {
autoUpdater.logger = logger
}
public start() {
setInterval(() => {
autoUpdater.checkForUpdatesAndNotify()
}, this.updateInterval)
return autoUpdater.checkForUpdatesAndNotify()
}
}
+287
View File
@@ -0,0 +1,287 @@
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"
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;
}
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.kubeConfig)
logger.debug(`Starting to load target definitions for ${ kc.currentContext }`)
const cluster = new Cluster({
id: clusterInfo.id,
port: this.port,
kubeConfig: clusterInfo.kubeConfig,
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}`)
}
});
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(config: string): KubeConfig {
const kc = new KubeConfig();
kc.loadFromString(config);
return kc;
}
protected async addNewCluster(clusterData: ClusterBaseInfo): Promise<Cluster> {
return new Promise(async (resolve, reject) => {
try {
const configs: KubeConfig[] = k8s.loadAndSplitConfig(clusterData.kubeConfig)
if(configs.length == 0) {
reject("No cluster contexts defined")
}
configs.forEach(c => {
k8s.valideConfig(c)
const cluster = new Cluster({
id: uuid(),
port: this.port,
kubeConfig: k8s.dumpConfigYaml(c),
preferences: clusterData.preferences,
workspace: clusterData.workspace
})
cluster.init(c)
cluster.save()
this.clusters.set(cluster.id, cluster)
resolve(cluster)
});
} catch(error) {
logger.error(error)
reject(error)
}
});
}
protected listenEvents() {
this.promiseIpc.on("addCluster", async (clusterData: ClusterBaseInfo) => {
logger.debug(`IPC: addCluster`)
const cluster = await this.addNewCluster(clusterData)
return {
addedCluster: this.clusterResponse(cluster),
allClusters: Array.from(this.getClusters()).map((cluster: Cluster) => this.clusterResponse(cluster))
}
});
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) => this.clusterResponse(cluster))
});
this.promiseIpc.on("getCluster", async (id: string) => {
logger.debug(`IPC: getCluster`)
const cluster = this.getCluster(id)
if (cluster) {
await cluster.refreshCluster()
return this.clusterResponse(cluster)
} 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: this.clusterResponse(cluster), 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: this.clusterResponse(cluster), 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 this.clusterResponse(cluster)
});
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) => this.clusterResponse(cluster))
});
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();
});
}
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())
}
public 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)
if (cluster) {
// we need to swap path prefix so that request is proxied to kube api
req.url = req.url.replace(`/${clusterId}`, "/api-kube")
}
}
} else {
const id = req.headers.host.split(".")[0]
cluster = this.clusters.get(id)
}
return cluster;
}
// TODO: remove this
protected clusterResponse(cluster: Cluster) {
return cluster.toClusterInfo()
}
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
}
}
+323
View File
@@ -0,0 +1,323 @@
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 { KubeConfig, CoreV1Api, AuthorizationV1Api, V1ResourceAttributes } from "@kubernetes/client-node"
import * as fm from "./feature-manager";
import { Kubectl } from "./kubectl";
import { PromiseIpc } from "electron-promise-ipc"
import request from "request-promise-native"
import { KubeconfigManager } from "./kubeconfig-manager"
enum ClusterStatus {
AccessGranted = 2,
AccessDenied = 1,
Offline = 0
}
export interface ClusterBaseInfo {
id: string;
kubeConfig: string;
preferences?: ClusterPreferences;
port?: number;
workspace?: string;
}
export interface ClusterInfo extends ClusterBaseInfo {
url: string;
apiUrl: string;
online?: boolean;
accessible?: boolean;
failureReason?: string;
nodes?: number;
version?: string;
distribution?: string;
isAdmin?: boolean;
features?: FeatureStatusMap;
kubeCtl?: Kubectl;
contextName: string;
}
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;
public kubeCtl: Kubectl
public kubeConfig: string;
public eventCount: number;
public preferences: ClusterPreferences;
protected eventPoller: NodeJS.Timeout;
protected promiseIpc = new PromiseIpc({ timeout: 2000 })
protected kubeconfigManager: KubeconfigManager;
constructor(clusterInfo: ClusterBaseInfo) {
if (clusterInfo) Object.assign(this, clusterInfo)
if (!this.preferences) this.preferences = {}
this.kubeconfigManager = new KubeconfigManager(this.kubeConfig)
}
public kubeconfigPath() {
return this.kubeconfigManager.getPath()
}
public async init(kc: KubeConfig) {
this.contextHandler = new ContextHandler(kc, this)
this.contextName = kc.currentContext
this.url = this.contextHandler.url
this.apiUrl = kc.getCurrentCluster().server
}
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()
if (connectionStatus == ClusterStatus.AccessGranted) {
this.accessible = true
} else {
this.accessible = false
}
if (connectionStatus > ClusterStatus.Offline) {
this.online = true
} else {
this.online = false
}
if (this.accessible) {
this.distribution = this.detectKubernetesDistribution(this.version)
this.features = await fm.getFeatures(this.contextHandler)
this.isAdmin = await this.isClusterAdmin()
this.nodes = await this.getNodeCount()
this.kubeCtl = new Kubectl(this.version)
this.kubeCtl.ensureKubectl()
}
this.eventCount = await this.getEventCount();
}
public updateKubeconfig(kubeconfig: string) {
const storedCluster = clusterStore.getCluster(this.id)
if (!storedCluster) { return }
this.kubeConfig = kubeconfig
this.save()
}
public getPrometheusApiPrefix() {
if (!this.preferences.prometheus?.prefix) {
return ""
}
return this.preferences.prometheus.prefix
}
public save() {
clusterStore.storeCluster(this)
}
public toClusterInfo(): ClusterInfo {
const clusterInfo: ClusterInfo = {
id: this.id,
workspace: this.workspace,
url: this.url,
contextName: this.contextHandler.kc.currentContext,
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,
kubeConfig: this.kubeConfig,
preferences: this.preferences
}
return clusterInfo;
}
protected async k8sRequest(path: string, opts?: request.RequestPromiseOptions) {
const options = Object.assign({
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}/api-kube${path}`, options)
}
protected async getConnectionStatus() {
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)}`)
if (error.statusCode) {
if (error.statusCode >= 400 && error.statusCode < 500) {
this.failureReason = "Invalid credentials";
return ClusterStatus.AccessDenied;
} else {
this.failureReason = error.error || error.message;
return ClusterStatus.Offline;
}
} else if (error.failed === true) {
if (error.timedOut === true) {
this.failureReason = "Connection timed out";
return ClusterStatus.Offline;
} else {
this.failureReason = "Failed to fetch credentials";
return ClusterStatus.AccessDenied;
}
}
this.failureReason = error.message;
return ClusterStatus.Offline;
}
}
public async canI(resourceAttributes: V1ResourceAttributes): Promise<boolean> {
const authApi = this.contextHandler.kc.makeApiClient(AuthorizationV1Api)
try {
const accessReview = await authApi.createSelfSubjectAccessReview({
apiVersion: "authorization.k8s.io/v1",
kind: "SelfSubjectAccessReview",
spec: { resourceAttributes }
})
return accessReview.body.status.allowed === true
} catch(error) {
logger.error(`failed to request selfSubjectAccessReview: ${error.message}`)
return false
}
}
protected async isClusterAdmin(): Promise<boolean> {
return this.canI({
namespace: "kube-system",
resource: "*",
verb: "create",
})
}
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"
}
return "vanilla"
}
protected async getNodeCount() {
try {
const response = await this.k8sRequest("/api/v1/nodes")
return response.items.length
} catch(error) {
logger.debug(`failed to request node list: ${error.message}`)
return null
}
}
public async getEventCount(): Promise<number> {
if (!this.isAdmin) {
return 0;
}
const client = this.contextHandler.kc.makeApiClient(CoreV1Api);
try {
const response = await client.listEventForAllNamespaces(false, null, null, null, 1000);
const uniqEventSources = new Set();
const warnings = response.body.items.filter(e => e.type !== 'Normal');
for (const w of warnings) {
if(w.involvedObject.kind === 'Pod') {
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)) {
uniqEventSources.add(w.involvedObject.uid);
}
continue;
} catch (error) {
continue;
}
} 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
});
return uniqEventSources.size + nodeNotificationCount;
} catch (error) {
logger.error("Failed to fetch event count: " + JSON.stringify(error))
return 0;
}
}
}
+213
View File
@@ -0,0 +1,213 @@
import { CoreV1Api, KubeConfig } from "@kubernetes/client-node"
import http from "http"
import { ServerOptions } from "http-proxy"
import * as url from "url"
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 { PrometheusProvider, PrometheusService } from "./prometheus/provider-registry"
export class ContextHandler {
public contextName: string
public id: string
public url: string
public kc: KubeConfig
public certData: string
public authCertData: string
public cluster: Cluster
protected apiTarget: ServerOptions
protected proxyTarget: ServerOptions
protected clusterUrl: url.UrlWithStringQuery
protected proxyServer: KubeAuthProxy
protected clientCert: string
protected clientKey: string
protected secureApiConnection = true
protected defaultNamespace: string
protected proxyPort: number
protected kubernetesApi: string
protected prometheusProvider: string
protected prometheusPath: string
protected clusterName: string
constructor(kc: KubeConfig, cluster: Cluster) {
this.id = cluster.id
this.kc = new KubeConfig()
this.kc.users = [
{
name: kc.getCurrentUser().name,
token: this.id
}
]
this.kc.contexts = [
{
name: kc.currentContext,
cluster: kc.getCurrentCluster().name,
user: kc.getCurrentUser().name,
namespace: kc.getContextObject(kc.currentContext).namespace
}
]
this.kc.setCurrentContext(kc.currentContext)
this.cluster = cluster
this.clusterUrl = url.parse(kc.getCurrentCluster().server)
this.contextName = kc.currentContext;
this.defaultNamespace = kc.getContextObject(kc.currentContext).namespace
this.url = `http://${this.id}.localhost:${cluster.port}/`
this.kubernetesApi = `http://127.0.0.1:${cluster.port}/${this.id}`
this.kc.clusters = [
{
name: kc.getCurrentCluster().name,
server: this.kubernetesApi,
skipTLSVerify: true
}
]
this.setClusterPreferences(cluster.preferences)
}
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 async resolvePrometheusPath(): Promise<string> {
const service = await this.getPrometheusService()
return `${service.namespace}/services/${service.service}:${service.port}`
}
public async getPrometheusProvider() {
if (!this.prometheusProvider) {
const service = await this.getPrometheusService()
logger.info(`using ${service.id} as prometheus provider`)
this.prometheusProvider = service.id
}
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
const prometheusPromises: Promise<PrometheusService>[] = providers.map(async (provider: PrometheusProvider): Promise<PrometheusService> => {
const apiClient = this.kc.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
}
}
}
public async getPrometheusPath(): Promise<string> {
if (this.prometheusPath) return this.prometheusPath
this.prometheusPath = await this.resolvePrometheusPath()
return this.prometheusPath
}
public async getApiTarget(isWatchRequest = false) {
if (this.apiTarget && !isWatchRequest) {
return this.apiTarget
}
const timeout = isWatchRequest ? 4 * 60 * 60 * 1000 : 30000 // 4 hours for watch request, 30 seconds for the rest
const apiTarget = await this.newApiTarget(timeout)
if (!isWatchRequest) {
this.apiTarget = apiTarget
}
return apiTarget
}
protected async newApiTarget(timeout: number) {
return {
changeOrigin: true,
timeout: timeout,
headers: {
"Host": this.clusterUrl.hostname
},
target: {
port: await this.resolveProxyPort(),
protocol: "http://",
host: "localhost",
path: this.clusterUrl.path
},
}
}
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)
}
this.proxyPort = serverPort
return serverPort
}
public applyHeaders(req: http.IncomingMessage) {
req.headers["authorization"] = `Bearer ${this.id}`
}
public async withTemporaryKubeconfig(callback: (kubeconfig: string) => Promise<any>) {
try {
await callback(this.cluster.kubeconfigPath())
} catch (error) {
throw(error)
}
}
public async ensureServer() {
if (!this.proxyServer) {
const proxyPort = await this.resolveProxyPort()
const proxyEnv = Object.assign({}, process.env)
if (this.cluster.preferences && this.cluster.preferences.httpsProxy) {
proxyEnv.HTTPS_PROXY = this.cluster.preferences.httpsProxy
}
this.proxyServer = new KubeAuthProxy(this.cluster, proxyPort, proxyEnv)
await this.proxyServer.run()
}
}
public stopServer() {
if (this.proxyServer) {
this.proxyServer.exit()
this.proxyServer = null
}
}
public proxyServerError() {
if (!this.proxyServer) {
return null
}
return this.proxyServer.lastError
}
}
+53
View File
@@ -0,0 +1,53 @@
import { ContextHandler } from "./context-handler"
import logger from "./logger";
import { Cluster } from "./cluster";
import { Feature, FeatureStatusMap } 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),
}
export async function getFeatures(clusterContext: ContextHandler): Promise<FeatureStatusMap> {
return new Promise<FeatureStatusMap>(async (resolve, reject) => {
const result: FeatureStatusMap = {};
logger.debug(`features for ${clusterContext.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 status = await feature.featureStatus(clusterContext.kc);
result[feature.name] = status
} else {
logger.error("ALL_FEATURES.hasOwnProperty(key) returned FALSE ?!?!?!?!")
}
}
logger.debug(`getFeatures resolving with features: ${JSON.stringify(result)}`);
resolve(result);
});
}
export async function installFeature(name: string, cluster: Cluster, config: any) {
const feature = ALL_FEATURES[name] as Feature
// TODO Figure out how to handle config stuff
await feature.install(cluster)
}
export async function upgradeFeature(name: string, cluster: Cluster, config: any) {
const feature = ALL_FEATURES[name] as Feature
// TODO Figure out how to handle config stuff
await feature.upgrade(cluster)
}
export async function uninstallFeature(name: string, cluster: Cluster) {
const feature = ALL_FEATURES[name] as Feature
await feature.uninstall(cluster)
}
+104
View File
@@ -0,0 +1,104 @@
import fs from "fs";
import path from "path"
import * as hb from "handlebars"
import { ResourceApplier } from "./resource-applier"
import { KubeConfig, CoreV1Api, Watch } from "@kubernetes/client-node"
import logger from "./logger";
import { Cluster } from "./cluster";
export type 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;
}
}
// 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 featureStatus(kc: KubeConfig): Promise<FeatureStatus>;
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') {
logger.debug(`namespace ${name} finally gone`)
req.abort();
resolve()
}
},
(err) => {
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)
const raw = fs.readFileSync(file);
console.log("raw file loaded");
if(f.endsWith('.hb')) {
console.log("processing HB template");
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());
}
});
return resources;
}
protected manifestPath() {
return path.join(__dirname, '..', 'features', this.name);
}
}
+11
View File
@@ -0,0 +1,11 @@
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}`
}
+114
View File
@@ -0,0 +1,114 @@
import { LensApiRequest } from "./router"
import { helmService } from "./helm-service"
import { LensApi } from "./lens-api"
import logger from "./logger"
class HelmApi extends LensApi {
public async listCharts(request: LensApiRequest) {
const { response } = request
const charts = await helmService.listCharts()
this.respondJson(response, charts)
}
public async getChart(request: LensApiRequest) {
const { params, query, response } = request
const chart = await helmService.getChart(params.repo, params.chart, query.get("version"))
this.respondJson(response, chart)
}
public async getChartValues(request: LensApiRequest) {
const { params, query, response } = request
const values = await helmService.getChartValues(params.repo, params.chart, query.get("version"))
this.respondJson(response, values)
}
public async installChart(request: LensApiRequest) {
const { payload, cluster, response } = request
try {
const result = await helmService.installChart(cluster, payload)
this.respondJson(response, result, 201)
} catch (error) {
logger.debug(error)
this.respondText(response, error, 422)
}
}
public async updateRelease(request: LensApiRequest) {
const { cluster, params, payload, response } = request
try {
const result = await helmService.updateRelease(cluster, params.release, params.namespace, payload )
this.respondJson(response, result)
} catch (error) {
logger.debug(error)
this.respondText(response, error, 422)
}
}
public async rollbackRelease(request: LensApiRequest) {
const { cluster, params, payload, response } = request
try {
const result = await helmService.rollback(cluster, params.release, params.namespace, payload.revision)
this.respondJson(response, result)
} catch (error) {
logger.debug(error)
this.respondText(response, error)
}
}
public async listReleases(request: LensApiRequest) {
const { cluster, params, response } = request
try {
const result = await helmService.listReleases(cluster, params.namespace)
this.respondJson(response, result)
} catch(error) {
logger.debug(error)
this.respondText(response, error)
}
}
public async getRelease(request: LensApiRequest) {
const { cluster, params, response } = request
try {
const result = await helmService.getRelease(cluster, params.release, params.namespace)
this.respondJson(response, result)
} catch (error) {
logger.debug(error)
this.respondText(response, error, 422)
}
}
public async getReleaseValues(request: LensApiRequest) {
const { cluster, params, response } = request
try {
const result = await helmService.getReleaseValues(cluster, params.release, params.namespace)
this.respondText(response, result)
} catch (error) {
logger.debug(error)
this.respondText(response, error, 422)
}
}
public async getReleaseHistory(request: LensApiRequest) {
const { cluster, params, response } = request
try {
const result = await helmService.getReleaseHistory(cluster, params.release, params.namespace)
this.respondJson(response, result)
} catch (error) {
logger.debug(error)
this.respondText(response, error, 422)
}
}
public async deleteRelease(request: LensApiRequest) {
const { cluster, params, response } = request
try {
const result = await helmService.deleteRelease(cluster, params.release, params.namespace)
this.respondJson(response, result)
} catch (error) {
logger.debug(error)
this.respondText(response, error, 422)
}
}
}
export const helmApi = new HelmApi()
+73
View File
@@ -0,0 +1,73 @@
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 { helmCli } from "./helm-cli"
type CachedYaml = {
entries: any;
}
export class HelmChartManager {
protected cache: any
protected repo: HelmRepo
constructor(repo: HelmRepo){
this.cache = HelmRepoManager.cache
this.repo = repo
}
public async chart(name: string) {
const charts = await this.charts()
return charts[name]
}
public async charts(): Promise<any> {
try {
const cachedYaml = await this.cachedYaml()
return cachedYaml["entries"]
} catch(error) {
logger.error(error)
return []
}
}
public async getReadme(name: string, version = "") {
const helm = await helmCli.binaryPath()
if(version && version != "") {
const { stdout, stderr} = await promiseExec(`"${helm}" show readme ${this.repo.name}/${name} --version ${version}`).catch((error) => { throw(error.stderr)})
return stdout
} else {
const { stdout, stderr} = await promiseExec(`"${helm}" show readme ${this.repo.name}/${name}`).catch((error) => { throw(error.stderr)})
return stdout
}
}
public async getValues(name: string, version = "") {
const helm = await helmCli.binaryPath()
if(version && version != "") {
const { stdout, stderr} = await promiseExec(`"${helm}" show values ${this.repo.name}/${name} --version ${version}`).catch((error) => { throw(error.stderr)})
return stdout
} else {
const { stdout, stderr} = await promiseExec(`"${helm}" show values ${this.repo.name}/${name}`).catch((error) => { throw(error.stderr)})
return stdout
}
}
protected async cachedYaml(): Promise<CachedYaml> {
if (!(this.repo.name in this.cache)) {
const cacheFile = await fs.promises.readFile(this.repo.cacheFilePath, 'utf-8')
const data = yaml.safeLoad(cacheFile)
for(const key in data["entries"]) {
data["entries"][key].forEach((version: any) => {
version['repo'] = this.repo.name
version['created'] = Date.parse(version.created).toString()
})
}
this.cache[this.repo.name] = Buffer.from(JSON.stringify(data))
}
return JSON.parse(this.cache[this.repo.name].toString())
}
}
+43
View File
@@ -0,0 +1,43 @@
import packageInfo from "../package.json"
import path from "path"
import { LensBinary, LensBinaryOpts } from "./lens-binary"
import { isProduction } from "../common/vars";
export class HelmCli extends LensBinary {
public constructor(baseDir: string, version: string) {
const opts: LensBinaryOpts = {
version,
baseDir: baseDir,
originalBinaryName: "helm",
newBinaryName: "helm3"
}
super(opts)
}
protected getTarName(): string | null {
return `${this.binaryName}-v${this.binaryVersion}-${this.platformName}-${this.arch}.tar.gz`
}
protected getUrl() {
return `https://get.helm.sh/helm-v${this.binaryVersion}-${this.platformName}-${this.arch}.tar.gz`
}
protected getBinaryPath() {
return path.join(this.dirname, this.binaryName)
}
protected getOriginalBinaryPath() {
return path.join(this.dirname, this.platformName + "-" + this.arch, this.originalBinaryName)
}
}
const helmVersion = packageInfo.config.bundledHelmVersion;
let baseDir = process.resourcesPath;
if (!isProduction) {
baseDir = path.join(process.cwd(), "binaries", "client");
}
export const helmCli = new HelmCli(baseDir, helmVersion);
+111
View File
@@ -0,0 +1,111 @@
import * as tempy from "tempy";
import fs from "fs";
import * as yaml from "js-yaml";
import { promiseExec} from "./promise-exec"
import { helmCli } from "./helm-cli";
import { Cluster } from "./cluster";
import { toCamelCase } from "../common/utils/camelCase";
export class HelmReleaseManager {
public async listReleases(pathToKubeconfig: string, namespace?: string) {
const helm = await helmCli.binaryPath()
const namespaceFlag = namespace ? `-n ${namespace}` : "--all-namespaces"
const { stdout } = await promiseExec(`"${helm}" ls --output json ${namespaceFlag} --kubeconfig ${pathToKubeconfig}`).catch((error) => { throw(error.stderr)})
const output = JSON.parse(stdout)
if (output.length == 0) {
return output
}
output.forEach((release: any, index: number) => {
output[index] = toCamelCase(release)
});
return output
}
public async installChart(chart: string, values: any, name: string, namespace: string, version: string, pathToKubeconfig: string){
const helm = await helmCli.binaryPath()
const fileName = tempy.file({name: "values.yaml"})
await fs.promises.writeFile(fileName, yaml.safeDump(values))
try {
let generateName = ""
if (!name) {
generateName = "--generate-name"
name = ""
}
const { stdout, stderr } = await promiseExec(`"${helm}" install ${name} ${chart} --version ${version} -f ${fileName} --namespace ${namespace} --kubeconfig ${pathToKubeconfig} ${generateName}`).catch((error) => { throw(error.stderr)})
const releaseName = stdout.split("\n")[0].split(' ')[1].trim()
return {
log: stdout,
release: {
name: releaseName,
namespace: namespace
}
}
} finally {
await fs.promises.unlink(fileName)
}
}
public async upgradeRelease(name: string, chart: string, values: any, namespace: string, version: string, cluster: Cluster){
const helm = await helmCli.binaryPath()
const fileName = tempy.file({name: "values.yaml"})
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.kubeconfigPath()}`).catch((error) => { throw(error.stderr)})
return {
log: stdout,
release: this.getRelease(name, namespace, cluster)
}
} finally {
await fs.promises.unlink(fileName)
}
}
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.kubeconfigPath()}`).catch((error) => { throw(error.stderr)})
const release = JSON.parse(stdout)
release.resources = await this.getResources(name, namespace, cluster)
return release
}
public async deleteRelease(name: string, namespace: string, pathToKubeconfig: string) {
const helm = await helmCli.binaryPath()
const { stdout, stderr } = await promiseExec(`"${helm}" delete ${name} --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`).catch((error) => { throw(error.stderr)})
return stdout
}
public async getValues(name: string, namespace: string, pathToKubeconfig: string) {
const helm = await helmCli.binaryPath()
const { stdout, stderr } = await promiseExec(`"${helm}" get values ${name} --all --output yaml --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`).catch((error) => { throw(error.stderr)})
return stdout
}
public async getHistory(name: string, namespace: string, pathToKubeconfig: string) {
const helm = await helmCli.binaryPath()
const {stdout, stderr} = await promiseExec(`"${helm}" history ${name} --output json --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`).catch((error) => { throw(error.stderr)})
return JSON.parse(stdout)
}
public async rollback(name: string, namespace: string, revision: number, pathToKubeconfig: string) {
const helm = await helmCli.binaryPath()
const {stdout, stderr} = await promiseExec(`"${helm}" rollback ${name} ${revision} --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`).catch((error) => { throw(error.stderr)})
return stdout
}
protected async getResources(name: string, namespace: string, cluster: Cluster) {
const helm = await helmCli.binaryPath()
const kubectl = await cluster.kubeCtl.kubectlPath()
const pathToKubeconfig = cluster.kubeconfigPath()
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: []})}
})
return stdout
}
}
export const releaseManager = new HelmReleaseManager()
+155
View File
@@ -0,0 +1,155 @@
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()
+105
View File
@@ -0,0 +1,105 @@
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.kubeconfigPath())
return installResult
}
public async listCharts() {
const charts: any = {}
await repoManager.init()
const repositories = await repoManager.repositories()
for (const repo of repositories) {
charts[repo.name] = {}
const manager = new HelmChartManager(repo)
let entries = await manager.charts()
entries = this.excludeDeprecated(entries)
for(const key in entries) {
entries[key] = entries[key][0]
}
charts[repo.name] = entries
}
return charts
}
public async getChart(repoName: string, chartName: string, version = "") {
const result = {
readme: "",
versions: {}
}
const repo = await repoManager.repository(repoName)
const chartManager = new HelmChartManager(repo)
const chart = await chartManager.chart(chartName)
result.readme = await chartManager.getReadme(chartName, version)
result.versions = chart
return result
}
public async getChartValues(repoName: string, chartName: string, version = "") {
const repo = await repoManager.repository(repoName)
const chartManager = new HelmChartManager(repo)
return chartManager.getValues(chartName, version)
}
public async listReleases(cluster: Cluster, namespace: string = null) {
await repoManager.init()
const releases = await releaseManager.listReleases(cluster.kubeconfigPath(), namespace)
return releases
}
public async getRelease(cluster: Cluster, releaseName: string, namespace: string) {
logger.debug("Fetch release")
const release = await releaseManager.getRelease(releaseName, namespace, cluster)
return release
}
public async getReleaseValues(cluster: Cluster, releaseName: string, namespace: string) {
logger.debug("Fetch release values")
const values = await releaseManager.getValues(releaseName, namespace, cluster.kubeconfigPath())
return values
}
public async getReleaseHistory(cluster: Cluster, releaseName: string, namespace: string) {
logger.debug("Fetch release history")
const history = await releaseManager.getHistory(releaseName, namespace, cluster.kubeconfigPath())
return(history)
}
public async deleteRelease(cluster: Cluster, releaseName: string, namespace: string) {
logger.debug("Delete release")
const release = await releaseManager.deleteRelease(releaseName, namespace, cluster.kubeconfigPath())
return release
}
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
}
public async rollback(cluster: Cluster, releaseName: string, namespace: string, revision: number) {
logger.debug("Rollback release")
const output = await releaseManager.rollback(releaseName, namespace, revision, cluster.kubeconfigPath())
return({ message: output })
}
protected excludeDeprecated(entries: any) {
for(const key in entries) {
entries[key] = entries[key].filter((entry: any) => {
if(Array.isArray(entry)) {
return entry[0]['deprecated'] != true
}
return entry["deprecated"] != true
})
}
return entries
}
}
export const helmService = new HelmService()
+130
View File
@@ -0,0 +1,130 @@
// Main process
import "../common/system-ca"
import "../common/prometheus-providers"
import { app, dialog, protocol } from "electron"
import { PromiseIpc } from "electron-promise-ipc"
import path from "path"
import { format as formatUrl } from "url"
import logger from "./logger"
import initMenu from "./menu"
import * as proxy from "./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 { isDevelopment, isMac } from "../common/vars";
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;
const vmURL = isDevelopment ? `http://localhost:${process.env.ELECTRON_WEBPACK_WDS_PORT}` : formatUrl({
pathname: path.join(__dirname, "index.html"),
protocol: "file",
slashes: true,
})
async function main() {
await shellSync()
const updater = new AppUpdater()
updater.start();
tracker.event("app", "start");
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
try {
port = await getFreePort()
} catch (error) {
logger.error(error)
await dialog.showErrorBox("Lens Error", "Could not find a free port for the cluster proxy")
app.quit();
}
// create cluster manager
clusterManager = new ClusterManager(clusterStore.getAllClusterObjects(), port)
// run proxy
try {
proxy.listen(port, 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"}`)
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)
}
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(false)
windowManager.showMain(vmURL)
}
})
app.on("will-quit", async (event) => {
event.preventDefault(); // To allow mixpanel sending to be executed
if (clusterManager) clusterManager.stop()
app.exit(0);
})
+153
View File
@@ -0,0 +1,153 @@
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 valideConfig(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 string and breaks it into several configs. Each context
*
* @param configString yaml string of kube config
*/
export function loadAndSplitConfig(configString: string): k8s.KubeConfig[] {
const allConfigs = new k8s.KubeConfig();
allConfigs.loadFromString(configString);
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"
)
}
+108
View File
@@ -0,0 +1,108 @@
import { spawn, ChildProcess } from "child_process"
import logger from "./logger"
import * as tcpPortUsed from "tcp-port-used"
import { Kubectl, bundledKubectl } from "./kubectl"
import { Cluster } from "./cluster"
import { readFileSync, watch } from "fs"
import { PromiseIpc } from "electron-promise-ipc"
import { findMainWebContents } from "./webcontents"
import * as url from "url"
export class KubeAuthProxy {
public lastError: string
protected cluster: Cluster
protected env: NodeJS.ProcessEnv = null
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 new Promise((resolve, reject) => {
resolve()
})
}
const proxyBin = await this.kubectl.kubectlPath()
const configWatcher = watch(this.cluster.kubeconfigPath(), (eventType: string, filename: string) => {
if (eventType === "change") {
const kc = readFileSync(this.cluster.kubeconfigPath()).toString()
if (kc.trim().length > 0) { // Prevent updating empty configs back to store
this.cluster.updateKubeconfig(kc)
} else {
logger.warn(`kubeconfig watch on ${this.cluster.kubeconfigPath()} resulted into empty config, ignoring...`)
}
}
})
configWatcher.on("error", () => {})
const clusterUrl = url.parse(this.cluster.apiUrl)
let args = [
"proxy",
"-p", this.port.toString(),
"--kubeconfig", this.cluster.kubeconfigPath(),
"--accept-hosts", clusterUrl.hostname,
]
if (process.env.DEBUG_PROXY === "true") {
args = args.concat(["-v", "9"])
}
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((_) => {})
this.proxyProcess = null
configWatcher.close()
})
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")
})
return tcpPortUsed.waitUntilUsed(this.port, 500, 10000)
}
protected parseError(data: string) {
const error = data.split("http: proxy error:").slice(1).join("").trim()
let errorMsg = error
const jsonError = error.split("Response: ")[1]
if (jsonError) {
try {
const parsedError = JSON.parse(jsonError)
errorMsg = parsedError.error_description || parsedError.error || jsonError
} catch(_) {
errorMsg = jsonError.trim()
}
}
return errorMsg
}
protected async sendIpcLogMessage(data: string, stream: string) {
await this.promiseIpc.send(`kube-auth:${this.cluster.id}`, findMainWebContents(), { data: data, stream: stream })
}
public exit() {
if (this.proxyProcess) {
logger.debug(`Stopping local proxy: ${this.cluster.contextName}`)
this.proxyProcess.kill()
}
}
}
+32
View File
@@ -0,0 +1,32 @@
import { app } from "electron"
import fs from "fs"
import { ensureDir, randomFileName} from "./file-helpers"
import logger from "./logger"
export class KubeconfigManager {
protected configDir = app.getPath("temp")
protected kubeconfig: string
protected tempFile: string
constructor(kubeconfig: string) {
this.kubeconfig = kubeconfig
this.tempFile = this.createTemporaryKubeconfig()
}
public getPath() {
return this.tempFile
}
protected createTemporaryKubeconfig(): string {
ensureDir(this.configDir)
const path = `${this.configDir}/${randomFileName("kubeconfig")}`
logger.debug('Creating temporary kubeconfig: ' + path)
fs.writeFileSync(path, this.kubeconfig)
return path
}
public unlink() {
logger.debug('Deleting temporary kubeconfig: ' + this.tempFile)
fs.unlinkSync(this.tempFile)
}
}
+330
View File
@@ -0,0 +1,330 @@
import packageInfo from "../package.json"
import { app, remote } from "electron"
import path from "path"
import fs from "fs"
import request from "request"
import requestPromise from "request-promise-native"
import logger from "./logger"
import { ensureDir, pathExists } from "fs-extra"
import md5File from "md5-file"
import { globalRequestOpts } from "../common/request"
import lockFile from "proper-lockfile"
import { helmCli } from "./helm-cli"
import { userStore } from "../common/user-store"
import { isDevelopment, isMac, isWindows } from "../common/vars";
const bundledVersion = packageInfo.config.bundledKubectlVersion;
const kubectlMap: Map<string, string> = new Map([
["1.7", "1.8.15"],
["1.8", "1.9.10"],
["1.9", "1.10.13"],
["1.10", "1.11.10"],
["1.11", "1.12.10"],
["1.12", "1.13.12"],
["1.13", "1.13.12"],
["1.14", "1.14.10"],
["1.15", "1.15.11"],
["1.16", "1.16.8"],
["1.17", bundledVersion],
["1.18", "1.18.0"]
])
const packageMirrors: Map<string, string> = new Map([
["default", "https://storage.googleapis.com/kubernetes-release/release"],
["china", "https://mirror.azure.cn/kubernetes/kubectl"]
])
const initScriptVersionString = "# lens-initscript v3\n"
let bundledPath: string = null
if (isDevelopment) {
bundledPath = path.join(process.cwd(), "binaries", "client", process.platform, process.arch, "kubectl")
}
else {
bundledPath = path.join(process.resourcesPath, process.arch, "kubectl")
}
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")
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)
return Kubectl.bundledInstance
}
constructor(clusterVersion: string) {
const versionParts = /^v?(\d+\.\d+)(.*)/.exec(clusterVersion)
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)) {
this.kubectlVersion = kubectlMap.get(minorVersion)
logger.debug("Set kubectl version " + this.kubectlVersion + " for cluster version " + clusterVersion + " using version map")
}
else {
this.kubectlVersion = versionParts[1] + versionParts[2]
logger.debug("Set kubectl version " + this.kubectlVersion + " for cluster version " + clusterVersion + " using fallback")
}
let arch = null
if (process.arch == "x64") {
arch = "amd64"
}
else if (process.arch == "x86" || process.arch == "ia32") {
arch = "386"
}
else {
arch = process.arch
}
const platformName = isWindows ? "windows" : process.platform
const binaryName = isWindows ? "kubectl.exe" : "kubectl"
this.url = `${this.getDownloadMirror()}/v${this.kubectlVersion}/bin/${platformName}/${arch}/${binaryName}`
this.dirname = path.normalize(path.join(Kubectl.kubectlDir, this.kubectlVersion))
this.path = path.join(this.dirname, binaryName)
}
public async kubectlPath(): Promise<string> {
try {
await this.ensureKubectl()
return this.path
} catch (err) {
logger.error("Failed to ensure kubectl, fallback to the bundled version")
logger.error(err)
return Kubectl.bundledKubectlPath
}
}
public async binDir() {
try {
await this.ensureKubectl()
return this.dirname
} catch (err) {
logger.error(err)
return ""
}
}
protected async urlEtag() {
const response = await requestPromise({
method: "HEAD",
uri: this.url,
resolveWithFullResponse: true,
timeout: 4000,
...this.getRequestOpts()
}).catch((error) => {
logger.error(error)
})
if (response && response.headers["etag"]) {
return response.headers["etag"].replace(/"/g, "")
}
return ""
}
public async checkBinary(checkTag = true) {
const exists = await pathExists(this.path)
if (exists) {
if (!checkTag) {
return true
}
const hash = md5File.sync(this.path)
const etag = await this.urlEtag()
if (etag === "") {
logger.debug("Cannot resolve kubectl remote etag")
return true
}
if (hash == etag) {
logger.debug("Kubectl md5sum matches the remote etag")
return true
}
logger.error("Kubectl md5sum " + hash + " does not match the remote etag " + etag + ", unlinking and downloading again")
await fs.promises.unlink(this.path)
}
return false
}
protected async checkBundled(): Promise<boolean> {
if (this.kubectlVersion === Kubectl.bundledKubectlVersion) {
try {
const exist = await pathExists(this.path)
if (!exist) {
await fs.promises.copyFile(Kubectl.bundledKubectlPath, this.path)
await fs.promises.chmod(this.path, 0o755)
}
return true
} catch (err) {
logger.error("Could not copy the bundled kubectl to app-data: " + err)
return false
}
}
else {
return false
}
}
public async ensureKubectl(): Promise<boolean> {
await ensureDir(this.dirname, 0o755)
return lockFile.lock(this.dirname).then(async (release) => {
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)
});
}
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
}).catch((e) => {
logger.error(`Failed to get a lock for ${this.kubectlVersion}`)
logger.error(e)
return false
})
}
public async downloadKubectl() {
await ensureDir(path.dirname(this.path), 0o755)
logger.info(`Downloading kubectl ${this.kubectlVersion} from ${this.url} to ${this.path}`)
return new Promise((resolve, reject) => {
const stream = request({
gzip: true,
...this.getRequestOpts()
})
const file = fs.createWriteStream(this.path)
stream.on("complete", () => {
logger.debug("kubectl binary download finished")
file.end(() => {
})
})
stream.on("error", (error) => {
logger.error(error)
fs.unlink(this.path, () => {
})
reject(error)
})
file.on("close", () => {
logger.debug("kubectl binary download closed")
fs.chmod(this.path, 0o755, () => {
})
resolve()
})
stream.pipe(file)
})
}
protected async scriptIsLatest(scriptPath: string) {
const scriptExists = await pathExists(scriptPath)
if (!scriptExists) return false
try {
const filehandle = await fs.promises.open(scriptPath, 'r')
const buffer = Buffer.alloc(40)
await filehandle.read(buffer, 0, 40, 0)
await filehandle.close()
return buffer.toString().startsWith(initScriptVersionString)
} catch (err) {
logger.error(err)
return false
}
}
protected async writeInitScripts() {
const helmPath = helmCli.getBinaryDir()
const fsPromises = fs.promises;
const bashScriptPath = path.join(this.dirname, '.bash_set_path')
const bashScriptIsLatest = await this.scriptIsLatest(bashScriptPath)
if (!bashScriptIsLatest) {
let bashScript = "" + initScriptVersionString
bashScript += "tempkubeconfig=\"$KUBECONFIG\"\n"
bashScript += "test -f \"/etc/profile\" && . \"/etc/profile\"\n"
bashScript += "if test -f \"$HOME/.bash_profile\"; then\n"
bashScript += " . \"$HOME/.bash_profile\"\n"
bashScript += "elif test -f \"$HOME/.bash_login\"; then\n"
bashScript += " . \"$HOME/.bash_login\"\n"
bashScript += "elif test -f \"$HOME/.profile\"; then\n"
bashScript += " . \"$HOME/.profile\"\n"
bashScript += "fi\n"
bashScript += `export PATH="${this.dirname}:${helmPath}:$PATH"\n`
bashScript += "export KUBECONFIG=\"$tempkubeconfig\"\n"
bashScript += "unset tempkubeconfig\n"
await fsPromises.writeFile(bashScriptPath, bashScript.toString(), { mode: 0o644 })
}
const zshScriptPath = path.join(this.dirname, '.zlogin')
const zshScriptIsLatest = await this.scriptIsLatest(zshScriptPath)
if (!zshScriptIsLatest) {
let zshScript = "" + initScriptVersionString
zshScript += "tempkubeconfig=\"$KUBECONFIG\"\n"
// restore previous ZDOTDIR
zshScript += "export ZDOTDIR=\"$OLD_ZDOTDIR\"\n"
// source all the files
zshScript += "test -f \"$OLD_ZDOTDIR/.zshenv\" && . \"$OLD_ZDOTDIR/.zshenv\"\n"
zshScript += "test -f \"$OLD_ZDOTDIR/.zprofile\" && . \"$OLD_ZDOTDIR/.zprofile\"\n"
zshScript += "test -f \"$OLD_ZDOTDIR/.zlogin\" && . \"$OLD_ZDOTDIR/.zlogin\"\n"
zshScript += "test -f \"$OLD_ZDOTDIR/.zshrc\" && . \"$OLD_ZDOTDIR/.zshrc\"\n"
// voodoo to replace any previous occurences of kubectl path in the PATH
zshScript += `kubectlpath=\"${this.dirname}"\n`
zshScript += `helmpath=\"${helmPath}"\n`
zshScript += "p=\":$kubectlpath:\"\n"
zshScript += "d=\":$PATH:\"\n"
zshScript += "d=${d//$p/:}\n"
zshScript += "d=${d/#:/}\n"
zshScript += "export PATH=\"$kubectlpath:$helmpath:${d/%:/}\"\n"
zshScript += "export KUBECONFIG=\"$tempkubeconfig\"\n"
zshScript += "unset tempkubeconfig\n"
zshScript += "unset OLD_ZDOTDIR\n"
await fsPromises.writeFile(zshScriptPath, zshScript.toString(), { mode: 0o644 })
}
}
protected getRequestOpts() {
return globalRequestOpts({
url: this.url
})
}
protected getDownloadMirror() {
if (isMac) {
return packageMirrors.get("default") // MacOS packages are only available from default
}
const mirror = packageMirrors.get(userStore.getPreferences().downloadMirror)
if (mirror) {
return mirror
}
return packageMirrors.get("default")
}
}
const bundledKubectl = Kubectl.bundled()
export { bundledKubectl }
+17
View File
@@ -0,0 +1,17 @@
import http from "http";
export abstract class LensApi {
protected respondJson(res: http.ServerResponse, content: {}, status = 200) {
this.respond(res, JSON.stringify(content), "application/json", status)
}
protected respondText(res: http.ServerResponse, content: string, status = 200) {
this.respond(res, content, "text/plain", status)
}
protected respond(res: http.ServerResponse, content: string, contentType: string, status = 200) {
res.setHeader("Content-Type", contentType)
res.statusCode = status
res.end(content)
}
}
+182
View File
@@ -0,0 +1,182 @@
import path from "path"
import fs from "fs"
import request from "request"
import logger from "./logger"
import { ensureDir, pathExists } from "fs-extra"
import * as tar from "tar"
import { isWindows } from "../common/vars";
export type LensBinaryOpts = {
version: string;
baseDir: string;
originalBinaryName: string;
newBinaryName?: string;
requestOpts?: request.Options;
}
export class LensBinary {
public binaryVersion: string
protected directory: string
protected url: string
protected path: string;
protected tarPath: string;
protected dirname: string
protected binaryName: string
protected platformName: string
protected arch: string
protected originalBinaryName: string
protected requestOpts: request.Options
constructor(opts: LensBinaryOpts) {
const baseDir = opts.baseDir
this.originalBinaryName = opts.originalBinaryName
this.binaryName = opts.newBinaryName || opts.originalBinaryName
this.binaryVersion = opts.version
this.requestOpts = opts.requestOpts
let arch = null
if (process.arch == "x64") {
arch = "amd64"
}
else if (process.arch == "x86" || process.arch == "ia32") {
arch = "386"
}
else {
arch = process.arch
}
this.arch = arch
this.platformName = isWindows ? "windows" : process.platform
this.dirname = path.normalize(path.join(baseDir, this.binaryName))
if (isWindows) {
this.binaryName = this.binaryName + ".exe"
this.originalBinaryName = this.originalBinaryName + ".exe"
}
const tarName = this.getTarName()
if (tarName) {
this.tarPath = path.join(this.dirname, tarName)
}
}
protected binaryDir() {
throw new Error("binaryDir not implemented")
}
public async binaryPath() {
await this.ensureBinary()
return this.getBinaryPath()
}
protected getTarName(): string | null {
return null
}
protected getUrl() {
return ""
}
protected getBinaryPath() {
return ""
}
protected getOriginalBinaryPath() {
return ""
}
public getBinaryDir() {
return path.dirname(this.getBinaryPath())
}
public async binDir() {
try {
await this.ensureBinary()
return this.dirname
} catch (err) {
logger.error(err)
return ""
}
}
protected async checkBinary() {
const exists = await pathExists(this.getBinaryPath())
return exists
}
public async ensureBinary() {
const isValid = await this.checkBinary()
if (!isValid) {
await this.downloadBinary().catch((error) => {
logger.error(error)
});
if (this.tarPath) await this.untarBinary()
if (this.originalBinaryName != this.binaryName) await this.renameBinary()
logger.info(`${this.originalBinaryName} has been downloaded to ${this.getBinaryPath()}`)
}
}
protected async untarBinary() {
return new Promise<void>((resolve, reject) => {
logger.debug(`Extracting ${this.originalBinaryName} binary`)
tar.x({
file: this.tarPath,
cwd: this.dirname
}).then((_ => {
resolve()
}))
})
}
protected async renameBinary() {
return new Promise<void>((resolve, reject) => {
logger.debug(`Renaming ${this.originalBinaryName} binary to ${this.binaryName}`)
fs.rename(this.getOriginalBinaryPath(), this.getBinaryPath(), (err) => {
if (err) {
reject(err)
}
else {
resolve()
}
})
})
}
protected async downloadBinary() {
const binaryPath = this.tarPath || this.getBinaryPath()
await ensureDir(this.getBinaryDir(), 0o755)
const file = fs.createWriteStream(binaryPath)
const url = this.getUrl()
logger.info(`Downloading ${this.originalBinaryName} ${this.binaryVersion} from ${url} to ${binaryPath}`)
const requestOpts: request.UriOptions & request.CoreOptions = {
uri: url,
gzip: true,
...this.requestOpts
}
const stream = request(requestOpts)
stream.on("complete", () => {
logger.info(`Download of ${this.originalBinaryName} finished`)
file.end(() => {
})
})
stream.on("error", (error) => {
logger.error(error)
fs.unlink(binaryPath, () => {
})
throw(error)
})
return new Promise((resolve, reject) => {
file.on("close", () => {
logger.debug(`${this.originalBinaryName} binary download closed`)
if (!this.tarPath) fs.chmod(binaryPath, 0o755, () => {
})
resolve()
})
stream.pipe(file)
})
}
}
+20
View File
@@ -0,0 +1,20 @@
import * as winston from "winston"
const options = {
colorize: true,
handleExceptions: false,
json: false,
level: process.env.DEBUG === "true" ? "debug" : "info",
}
const logger = winston.createLogger({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple(),
),
transports: [
new winston.transports.Console(options),
],
});
export default logger
+217
View File
@@ -0,0 +1,217 @@
import { app, BrowserWindow, dialog, Menu, MenuItem, MenuItemConstructorOptions, shell, webContents } from "electron"
import { isDevelopment, isMac, isWindows } from "../common/vars";
// 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
}
function setClusterSettingsEnabled(enabled: boolean) {
const menuIndex = isMac ? 1 : 0
Menu.getApplicationMenu().items[menuIndex].submenu.items[1].enabled = enabled
}
function showAbout(_menuitem: MenuItem, browserWindow: BrowserWindow) {
const appDetails = [
`Version: ${app.getVersion()}`,
]
appDetails.push(`Copyright 2020 Lakend Labs, Inc.`)
let title = "Lens"
if (isWindows) {
title = ` ${title}`
}
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
},
{ type: 'separator' },
{
label: 'Preferences',
click: opts.showPreferencesHook,
enabled: true
},
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' }
]
};
if (isMac) {
mt.push(macAppMenu);
}
let fileMenu: MenuItemConstructorOptions;
if (isMac) {
fileMenu = {
label: 'File',
submenu: [{
label: 'Add Cluster...',
click: opts.addClusterHook,
},
{
label: 'Cluster Settings',
click: opts.clusterSettingsHook,
enabled: false
}
]
}
}
else {
fileMenu = {
label: 'File',
submenu: [
{
label: 'Add Cluster...',
click: opts.addClusterHook,
},
{
label: 'Cluster Settings',
click: opts.clusterSettingsHook,
enabled: false
},
{ type: 'separator' },
{
label: 'Preferences',
click: opts.showPreferencesHook,
enabled: true
},
{ type: 'separator' },
{ role: 'quit' }
]
}
}
mt.push(fileMenu);
const editMenu: MenuItemConstructorOptions = {
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'delete' },
{ type: 'separator' },
{ role: 'selectAll' },
]
};
mt.push(editMenu);
const viewMenu: MenuItemConstructorOptions = {
label: 'View',
submenu: [
{
label: 'Back',
accelerator: 'CmdOrCtrl+[',
click() {
webContents.getFocusedWebContents().executeJavaScript('window.history.back()')
}
},
{
label: 'Forward',
accelerator: 'CmdOrCtrl+]',
click() {
webContents.getFocusedWebContents().executeJavaScript('window.history.forward()')
}
},
{
label: 'Reload',
accelerator: 'CmdOrCtrl+R',
click() {
webContents.getFocusedWebContents().reload()
}
},
...(isDevelopment ? [
{ role: 'toggleDevTools' } as MenuItemConstructorOptions,
{
label: 'Open Dashboard Devtools',
click() {
webContents.getFocusedWebContents().openDevTools()
}
}
] : []),
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' }
]
};
mt.push(viewMenu);
const helpMenu: MenuItemConstructorOptions = {
role: 'help',
submenu: [
{
label: 'License',
click: async () => {
shell.openExternal('https://lakendlabs.com/licenses/lens-eula.md');
},
},
{
label: 'Community Slack',
click: async () => {
shell.openExternal('https://join.slack.com/t/k8slens/shared_invite/enQtOTc5NjAyNjYyOTk4LWU1NDQ0ZGFkOWJkNTRhYTc2YjVmZDdkM2FkNGM5MjhiYTRhMDU2NDQ1MzIyMDA4ZGZlNmExOTc0N2JmY2M3ZGI');
},
},
{
label: 'Report an Issue',
click: async () => {
shell.openExternal('https://github.com/lensapp/lens/issues');
},
},
{
label: "What's new?",
click: opts.showWhatsNewHook,
},
...(!isMac ? [{
label: "About Lens",
click: showAbout
} as MenuItemConstructorOptions] : [])
]
};
mt.push(helpMenu);
const menu = Menu.buildFromTemplate(mt);
Menu.setApplicationMenu(menu);
promiseIpc.on("enableClusterSettingsMenuItem", (clusterId: string) => {
setClusterSettingsEnabled(true)
});
promiseIpc.on("disableClusterSettingsMenuItem", () => {
setClusterSettingsEnabled(false)
});
};
+144
View File
@@ -0,0 +1,144 @@
import * as WebSocket from "ws"
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 logger from "./logger";
import { KubeConfig, V1Pod } from "@kubernetes/client-node";
import { tracker } from "./tracker"
import { Cluster, ClusterPreferences } from "./cluster"
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)
this.nodeName = nodeName
this.podId = `node-shell-${uuid()}`
this.kc = cluster.contextHandler.kc
}
public async open() {
const shell = await this.kubectl.kubectlPath()
let args = []
if (this.createNodeShellPod(this.podId, this.nodeName)) {
await this.waitForRunningPod(this.podId).catch((error) => {
this.exit(1001)
})
}
args = ["exec", "-i", "-t", "-n", "kube-system", this.podId, "--", "sh", "-c", "((clear && bash) || (clear && ash) || (clear && sh))"]
const shellEnv = await this.getCachedShellEnv()
this.shellProcess = pty.spawn(shell, args, {
cols: 80,
cwd: this.cwd() || shellEnv["HOME"],
env: shellEnv,
name: "xterm-256color",
rows: 30,
});
this.running = true;
this.pipeStdout()
this.pipeStdin()
this.closeWebsocketOnProcessExit()
this.exitProcessOnWebsocketClose()
tracker.event("node-shell", "open")
}
protected exit(code = 1000) {
if (this.podId) {
this.deleteNodeShellPod()
}
super.exit(code)
}
protected async createNodeShellPod(podId: string, nodeName: string) {
const kc = this.getKubeConfig();
const k8sApi = kc.makeApiClient(k8s.CoreV1Api);
const pod = {
metadata: {
name: podId,
namespace: "kube-system"
},
spec: {
restartPolicy: "Never",
terminationGracePeriodSeconds: 0,
hostPID: true,
hostIPC: true,
hostNetwork: true,
tolerations: [{
operator: "Exists"
}],
containers: [{
name: "shell",
image: "docker.io/alpine:3.9",
securityContext: {
privileged: true,
},
command: ["nsenter"],
args: ["-t", "1", "-m", "-u", "-i", "-n", "sleep", "14000"]
}],
nodeSelector: {
"kubernetes.io/hostname": nodeName
}
}
} as k8s.V1Pod;
await k8sApi.createNamespacedPod("kube-system", pod).catch((error) => {
logger.error(error)
return false
})
return true
}
protected getKubeConfig() {
if (this.kc) {
return this.kc
}
this.kc = new k8s.KubeConfig();
this.kc.loadFromFile(this.kubeconfigPath)
return this.kc
}
protected waitForRunningPod(podId: string) {
return new Promise<boolean>(async (resolve, reject) => {
const kc = this.getKubeConfig();
const watch = new k8s.Watch(kc);
const req = await watch.watch(`/api/v1/namespaces/kube-system/pods`, {},
// callback is called for each received object.
(_type, obj) => {
if (obj.metadata.name == podId && obj.status.phase === "Running") {
resolve(true)
}
},
// done callback is called if the watch terminates normally
(err) => {
logger.error(err)
reject(false)
}
);
setTimeout(() => { req.abort(); reject(false); }, 120 * 1000);
})
}
protected deleteNodeShellPod() {
const kc = this.getKubeConfig();
const k8sApi = kc.makeApiClient(k8s.CoreV1Api);
k8sApi.deleteNamespacedPod(this.podId, "kube-system")
}
}
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)
})
}
+30
View File
@@ -0,0 +1,30 @@
import logger from "./logger"
import { createServer } from "net"
import { 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}))
}
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
}
+29
View File
@@ -0,0 +1,29 @@
import { PrometheusLens } from "./lens"
import { CoreV1Api } from "@kubernetes/client-node"
import { PrometheusService } from "./provider-registry";
import logger from "../logger"
export class PrometheusHelm extends PrometheusLens {
id = "helm"
name = "Helm"
rateAccuracy = "5m"
public async getPrometheusService(client: CoreV1Api): Promise<PrometheusService> {
const labelSelector = "app=prometheus,component=server,heritage=Helm"
try {
const serviceList = await client.listServiceForAllNamespaces(false, "", null, labelSelector)
const service = serviceList.body.items[0]
if (!service) return
return {
id: this.id,
namespace: service.metadata.namespace,
service: service.metadata.name,
port: service.spec.ports[0].port
}
} catch(error) {
logger.warn(`PrometheusHelm: failed to list services: ${error.toString()}`)
return
}
}
}
+83
View File
@@ -0,0 +1,83 @@
import { PrometheusProvider, PrometheusQueryOpts, PrometheusQuery, PrometheusService } from "./provider-registry";
import { CoreV1Api } from "@kubernetes/client-node";
import logger from "../logger"
export class PrometheusLens implements PrometheusProvider {
id = "lens"
name = "Lens"
rateAccuracy = "1m"
public async getPrometheusService(client: CoreV1Api): Promise<PrometheusService> {
try {
const resp = await client.readNamespacedService("prometheus", "lens-metrics")
const service = resp.body
return {
id: this.id,
namespace: service.metadata.namespace,
service: service.metadata.name,
port: service.spec.ports[0].port
}
} catch(error) {
logger.warn(`PrometheusLens: failed to list services: ${error.toString()}`)
}
}
public getQueries(opts: PrometheusQueryOpts): PrometheusQuery {
switch(opts.category) {
case 'cluster':
return {
memoryUsage: `
sum(
node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes)
) by (kubernetes_name)
`.replace(/_bytes/g, `_bytes{kubernetes_node=~"${opts.nodes}"}`),
memoryRequests: `sum(kube_pod_container_resource_requests{node=~"${opts.nodes}", resource="memory"}) by (component)`,
memoryLimits: `sum(kube_pod_container_resource_limits{node=~"${opts.nodes}", resource="memory"}) by (component)`,
memoryCapacity: `sum(kube_node_status_capacity{node=~"${opts.nodes}", resource="memory"}) by (component)`,
cpuUsage: `sum(rate(node_cpu_seconds_total{kubernetes_node=~"${opts.nodes}", mode=~"user|system"}[${this.rateAccuracy}]))`,
cpuRequests:`sum(kube_pod_container_resource_requests{node=~"${opts.nodes}", resource="cpu"}) by (component)`,
cpuLimits: `sum(kube_pod_container_resource_limits{node=~"${opts.nodes}", resource="cpu"}) by (component)`,
cpuCapacity: `sum(kube_node_status_capacity{node=~"${opts.nodes}", resource="cpu"}) by (component)`,
podUsage: `sum(kubelet_running_pod_count{instance=~"${opts.nodes}"})`,
podCapacity: `sum(kube_node_status_capacity{node=~"${opts.nodes}", resource="pods"}) by (component)`,
fsSize: `sum(node_filesystem_size_bytes{kubernetes_node=~"${opts.nodes}", mountpoint="/"}) by (kubernetes_node)`,
fsUsage: `sum(node_filesystem_size_bytes{kubernetes_node=~"${opts.nodes}", mountpoint="/"} - node_filesystem_avail_bytes{kubernetes_node=~"${opts.nodes}", mountpoint="/"}) by (kubernetes_node)`
}
case 'nodes':
return {
memoryUsage: `sum (node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes)) by (kubernetes_node)`,
memoryCapacity: `sum(kube_node_status_capacity{resource="memory"}) by (node)`,
cpuUsage: `sum(rate(node_cpu_seconds_total{mode=~"user|system"}[${this.rateAccuracy}])) by(kubernetes_node)`,
cpuCapacity: `sum(kube_node_status_allocatable{resource="cpu"}) by (node)`,
fsSize: `sum(node_filesystem_size_bytes{mountpoint="/"}) by (kubernetes_node)`,
fsUsage: `sum(node_filesystem_size_bytes{mountpoint="/"} - node_filesystem_avail_bytes{mountpoint="/"}) by (kubernetes_node)`
}
case 'pods':
return {
cpuUsage: `sum(rate(container_cpu_usage_seconds_total{container!="POD",container!="",pod=~"${opts.pods}",namespace="${opts.namespace}"}[${this.rateAccuracy}])) by (${opts.selector})`,
cpuRequests: `sum(kube_pod_container_resource_requests{pod=~"${opts.pods}",resource="cpu",namespace="${opts.namespace}"}) by (${opts.selector})`,
cpuLimits: `sum(kube_pod_container_resource_limits{pod=~"${opts.pods}",resource="cpu",namespace="${opts.namespace}"}) by (${opts.selector})`,
memoryUsage: `sum(container_memory_working_set_bytes{container!="POD",container!="",pod=~"${opts.pods}",namespace="${opts.namespace}"}) by (${opts.selector})`,
memoryRequests: `sum(kube_pod_container_resource_requests{pod=~"${opts.pods}",resource="memory",namespace="${opts.namespace}"}) by (${opts.selector})`,
memoryLimits: `sum(kube_pod_container_resource_limits{pod=~"${opts.pods}",resource="memory",namespace="${opts.namespace}"}) by (${opts.selector})`,
fsUsage: `sum(container_fs_usage_bytes{container!="POD",container!="",pod=~"${opts.pods}",namespace="${opts.namespace}"}) by (${opts.selector})`,
networkReceive: `sum(rate(container_network_receive_bytes_total{pod=~"${opts.pods}",namespace="${opts.namespace}"}[${this.rateAccuracy}])) by (${opts.selector})`,
networkTransmit: `sum(rate(container_network_transmit_bytes_total{pod=~"${opts.pods}",namespace="${opts.namespace}"}[${this.rateAccuracy}])) by (${opts.selector})`
}
case 'pvc':
return {
diskUsage: `sum(kubelet_volume_stats_used_bytes{persistentvolumeclaim="${opts.pvc}"}) by (persistentvolumeclaim, namespace)`,
diskCapacity: `sum(kubelet_volume_stats_capacity_bytes{persistentvolumeclaim="${opts.pvc}"}) by (persistentvolumeclaim, namespace)`
}
case 'ingress':
const bytesSent = (ingress: string, statuses: string) =>
`sum(rate(nginx_ingress_controller_bytes_sent_sum{ingress="${ingress}", status=~"${statuses}"}[${this.rateAccuracy}])) by (ingress)`
return {
bytesSentSuccess: bytesSent(opts.igress, "^2\\\\d*"),
bytesSentFailure: bytesSent(opts.ingres, "^5\\\\d*"),
requestDurationSeconds: `sum(rate(nginx_ingress_controller_request_duration_seconds_sum{ingress="${opts.ingress}"}[${this.rateAccuracy}])) by (ingress)`,
responseDurationSeconds: `sum(rate(nginx_ingress_controller_response_duration_seconds_sum{ingress="${opts.ingress}"}[${this.rateAccuracy}])) by (ingress)`
}
}
}
}
+91
View File
@@ -0,0 +1,91 @@
import { PrometheusProvider, PrometheusQueryOpts, PrometheusQuery, PrometheusService } from "./provider-registry";
import { CoreV1Api, V1Service } from "@kubernetes/client-node";
import logger from "../logger";
export class PrometheusOperator implements PrometheusProvider {
rateAccuracy = "1m"
id = "operator"
name = "Prometheus Operator"
public async getPrometheusService(client: CoreV1Api): Promise<PrometheusService> {
try {
let service: V1Service
for (const labelSelector of ["operated-prometheus=true", "self-monitor=true"]) {
if (!service) {
const serviceList = await client.listServiceForAllNamespaces(null, null, null, labelSelector)
service = serviceList.body.items[0]
}
}
if (!service) return
return {
id: this.id,
namespace: service.metadata.namespace,
service: service.metadata.name,
port: service.spec.ports[0].port
}
} catch(error) {
logger.warn(`PrometheusOperator: failed to list services: ${error.toString()}`)
return
}
}
public getQueries(opts: PrometheusQueryOpts): PrometheusQuery {
switch(opts.category) {
case 'cluster':
return {
memoryUsage: `
sum(
node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes)
) by (node)
`.replace(/_bytes/g, `_bytes * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"}`),
memoryRequests: `sum(kube_pod_container_resource_requests{node=~"${opts.nodes}", resource="memory"}) by (component)`,
memoryLimits: `sum(kube_pod_container_resource_limits{node=~"${opts.nodes}", resource="memory"}) by (component)`,
memoryCapacity: `sum(kube_node_status_capacity{node=~"${opts.nodes}", resource="memory"}) by (component)`,
cpuUsage: `sum(rate(node_cpu_seconds_total{mode=~"user|system"}[${this.rateAccuracy}])* on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"}) by (node)`,
cpuRequests:`sum(kube_pod_container_resource_requests{node=~"${opts.nodes}", resource="cpu"}) by (component)`,
cpuLimits: `sum(kube_pod_container_resource_limits{node=~"${opts.nodes}", resource="cpu"}) by (component)`,
cpuCapacity: `sum(kube_node_status_capacity{node=~"${opts.nodes}", resource="cpu"}) by (component)`,
podUsage: `sum(kubelet_running_pod_count{node=~"${opts.nodes}"})`,
podCapacity: `sum(kube_node_status_capacity{node=~"${opts.nodes}", resource="pods"}) by (component)`,
fsSize: `sum(node_filesystem_size_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"}) by (node)`,
fsUsage: `sum(node_filesystem_size_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"} - node_filesystem_avail_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"}) by (node)`
}
case 'nodes':
return {
memoryUsage: `sum((node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes)) * on (pod,namespace) group_left(node) kube_pod_info) by (node)`,
memoryCapacity: `sum(kube_node_status_capacity{resource="memory"}) by (node)`,
cpuUsage: `sum(rate(node_cpu_seconds_total{mode=~"user|system"}[${this.rateAccuracy}]) * on (pod,namespace) group_left(node) kube_pod_info) by (node)`,
cpuCapacity: `sum(kube_node_status_allocatable{resource="cpu"}) by (node)`,
fsSize: `sum(node_filesystem_size_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info) by (node)`,
fsUsage: `sum((node_filesystem_size_bytes{mountpoint="/"} - node_filesystem_avail_bytes{mountpoint="/"}) * on (pod,namespace) group_left(node) kube_pod_info) by (node)`
}
case 'pods':
return {
cpuUsage: `sum(rate(container_cpu_usage_seconds_total{container!="POD",container!="",pod=~"${opts.pods}",namespace="${opts.namespace}"}[${this.rateAccuracy}])) by (${opts.selector})`,
cpuRequests: `sum(kube_pod_container_resource_requests{pod=~"${opts.pods}",resource="cpu",namespace="${opts.namespace}"}) by (${opts.selector})`,
cpuLimits: `sum(kube_pod_container_resource_limits{pod=~"${opts.pods}",resource="cpu",namespace="${opts.namespace}"}) by (${opts.selector})`,
memoryUsage: `sum(container_memory_working_set_bytes{container!="POD",container!="",pod=~"${opts.pods}",namespace="${opts.namespace}"}) by (${opts.selector})`,
memoryRequests: `sum(kube_pod_container_resource_requests{pod=~"${opts.pods}",resource="memory",namespace="${opts.namespace}"}) by (${opts.selector})`,
memoryLimits: `sum(kube_pod_container_resource_limits{pod=~"${opts.pods}",resource="memory",namespace="${opts.namespace}"}) by (${opts.selector})`,
fsUsage: `sum(container_fs_usage_bytes{container!="POD",container!="",pod=~"${opts.pods}",namespace="${opts.namespace}"}) by (${opts.selector})`,
networkReceive: `sum(rate(container_network_receive_bytes_total{pod=~"${opts.pods}",namespace="${opts.namespace}"}[${this.rateAccuracy}])) by (${opts.selector})`,
networkTransmit: `sum(rate(container_network_transmit_bytes_total{pod=~"${opts.pods}",namespace="${opts.namespace}"}[${this.rateAccuracy}])) by (${opts.selector})`
}
case 'pvc':
return {
diskUsage: `sum(kubelet_volume_stats_used_bytes{persistentvolumeclaim="${opts.pvc}"}) by (persistentvolumeclaim, namespace)`,
diskCapacity: `sum(kubelet_volume_stats_capacity_bytes{persistentvolumeclaim="${opts.pvc}"}) by (persistentvolumeclaim, namespace)`
}
case 'ingress':
const bytesSent = (ingress: string, statuses: string) =>
`sum(rate(nginx_ingress_controller_bytes_sent_sum{ingress="${ingress}", status=~"${statuses}"}[${this.rateAccuracy}])) by (ingress)`;
return {
bytesSentSuccess: bytesSent(opts.igress, "^2\\\\d*"),
bytesSentFailure: bytesSent(opts.ingres, "^5\\\\d*"),
requestDurationSeconds: `sum(rate(nginx_ingress_controller_request_duration_seconds_sum{ingress="${opts.ingress}"}[${this.rateAccuracy}])) by (ingress)`,
responseDurationSeconds: `sum(rate(nginx_ingress_controller_response_duration_seconds_sum{ingress="${opts.ingress}"}[${this.rateAccuracy}])) by (ingress)`
}
}
}
}
+90
View File
@@ -0,0 +1,90 @@
import { CoreV1Api } from "@kubernetes/client-node"
export type PrometheusClusterQuery = {
memoryUsage: string;
memoryRequests: string;
memoryLimits: string;
memoryCapacity: string;
cpuUsage: string;
cpuRequests: string;
cpuLimits: string;
cpuCapacity: string;
podUsage: string;
podCapacity: string;
}
export type PrometheusNodeQuery = {
memoryUsage: string;
memoryCapacity: string;
cpuUsage: string;
cpuCapacity: string;
fsSize: string;
fsUsage: string;
}
export type PrometheusPodQuery = {
memoryUsage: string;
memoryRequests: string;
memoryLimits: string;
cpuUsage: string;
cpuRequests: string;
cpuLimits: string;
fsUsage: string;
networkReceive: string;
networkTransmit: string;
}
export type PrometheusPvcQuery = {
diskUsage: string;
diskCapacity: string;
}
export type PrometheusIngressQuery = {
bytesSentSuccess: string;
bytesSentFailure: string;
requestDurationSeconds: string;
responseDurationSeconds: string;
}
export type PrometheusQueryOpts = {
[key: string]: string | any;
};
export type PrometheusQuery = PrometheusNodeQuery | PrometheusClusterQuery | PrometheusPodQuery | PrometheusPvcQuery | PrometheusIngressQuery
export type PrometheusService = {
id: string;
namespace: string;
service: string;
port: number;
}
export interface PrometheusProvider {
id: string;
name: string;
getQueries(opts: PrometheusQueryOpts): PrometheusQuery;
getPrometheusService(client: CoreV1Api): Promise<PrometheusService>;
}
export type PrometheusProviderList = {
[key: string]: PrometheusProvider;
}
export class PrometheusProviderRegistry {
private static prometheusProviders: PrometheusProviderList = {}
static getProvider(type: string): PrometheusProvider {
if (!this.prometheusProviders[type]) {
throw "Unknown Prometheus provider";
}
return this.prometheusProviders[type]
}
static registerProvider(key: string, provider: PrometheusProvider) {
this.prometheusProviders[key] = provider
}
static getProviders(): PrometheusProvider[] {
return Object.values(this.prometheusProviders)
}
}
+4
View File
@@ -0,0 +1,4 @@
import * as util from "util"
import { exec } from "child_process";
export const promiseExec = util.promisify(exec)
+18
View File
@@ -0,0 +1,18 @@
import { app } from "electron"
const switchValue = app.commandLine.getSwitchValue("proxy-server")
export function mangleProxyEnv() {
let httpsProxy = process.env.HTTPS_PROXY || process.env.HTTP_PROXY || ""
delete process.env.HTTPS_PROXY
delete process.env.HTTP_PROXY
if (switchValue !== "") {
httpsProxy = switchValue
}
if (httpsProxy !== "") {
process.env.APP_HTTPS_PROXY = httpsProxy
}
}
+178
View File
@@ -0,0 +1,178 @@
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"
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
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")
}
protected buildProxyServer() {
const proxy = this.createProxy();
const proxyServer = http.createServer(function(req: http.IncomingMessage, res: http.ServerResponse) {
this.handleRequest(proxy, req, res);
}.bind(this));
proxyServer.on("upgrade", function(req: http.IncomingMessage, socket: Socket, head: Buffer) {
this.handleWsUpgrade(req, socket, head)
}.bind(this));
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().toString())
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 (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 = this.getNodeParam(req.url)
contextHandler.withTemporaryKubeconfig((kubeconfigPath) => {
return new Promise<boolean>(async (resolve, reject) => {
const shellSession = await shell.open(con, kubeconfigPath, cluster, nodeParam)
shellSession.on("exit", () => {
resolve(true)
})
})
})
}).bind(this))
return ws
}
protected getNodeParam(requestUrl: string) {
const reqUrl = url.parse(requestUrl, true)
const urlParams = reqUrl.query
let nodeParam: string = null
for (const [key, value] of Object.entries(urlParams)) {
if (key === "node") {
nodeParam = value.toString()
}
}
return nodeParam
}
protected async getProxyTarget(req: http.IncomingMessage, contextHandler: ContextHandler): Promise<httpProxy.ServerOptions> {
if (req.url.startsWith("/api-kube/")) {
delete req.headers.authorization
req.url = req.url.replace("/api-kube", "")
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
try {
contextHandler.applyHeaders(req)
} catch (error) {
res.statusCode = 503
res.end()
return
}
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();
const cluster = this.clusterManager.getClusterForRequest(req)
const contextHandler = cluster.contextHandler
contextHandler.applyHeaders(req);
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;
}
+17
View File
@@ -0,0 +1,17 @@
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.kubeconfigPath(), payload)
this.respondJson(response, [resource], 200)
} catch(error) {
this.respondText(response, error, 422)
}
}
}
export const resourceApplierApi = new ResourceApplierApi()
+101
View File
@@ -0,0 +1,101 @@
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;
};
};
}
export class ResourceApplier {
protected kubeconfigPath: string;
protected cluster: Cluster
constructor(cluster: Cluster, pathToKubeconfig: string) {
this.kubeconfigPath = pathToKubeconfig
this.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)
}
}
protected async kubectlApply(content: string): Promise<string> {
const kubectl = await this.cluster.kubeCtl.kubectlPath()
return new Promise<string>((resolve, reject) => {
const fileName = tempy.file({name: "resource.yaml"})
fs.writeFileSync(fileName, content)
const cmd = `"${kubectl}" apply --kubeconfig ${this.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
if (httpsProxy) {
execEnv["HTTPS_PROXY"] = httpsProxy
}
exec(cmd, { env: execEnv },
(error, stdout, stderr) => {
if (stderr != "") {
fs.unlinkSync(fileName)
reject(stderr)
return
}
fs.unlinkSync(fileName)
resolve(JSON.parse(stdout))
})
})
}
public async kubectlApplyAll(resources: string[]): Promise<string> {
const kubectl = await this.cluster.kubeCtl.kubectlPath()
return new Promise<string>((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}`
console.log("shooting manifests with:", cmd);
exec(cmd, (error, stdout, stderr) => {
if(error) {
reject("Error applying manifests:" + error);
}
if (stderr != "") {
reject(stderr)
return
}
resolve(stdout)
})
})
}
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']
}
}
}
export async function apply(cluster: Cluster, pathToKubeconfig: string, resource: any) {
const resourceApplier = new ResourceApplier(cluster, pathToKubeconfig)
return await resourceApplier.apply(resource)
}
+141
View File
@@ -0,0 +1,141 @@
import http from "http"
import path from "path"
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 { readFile } from "fs"
// eslint-disable-next-line @typescript-eslint/no-var-requires
const Call = require('@hapi/call');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const Subtext = require('@hapi/subtext');
const assetsPath = path.join(__static, "build/client")
const mimeTypes: {[key: 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 type LensApiRequest = {
cluster: Cluster;
payload: any;
raw: {
req: http.IncomingMessage;
};
params: RouteParams;
response: http.ServerResponse;
query: URLSearchParams;
path: string;
}
export class Router {
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")
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 })
await matchingRoute.route(request)
return true
} else {
return false
}
}
protected async getRequest(opts: { req: http.IncomingMessage; res: http.ServerResponse; cluster: Cluster; url: URL; params: RouteParams }) {
const { req, res, url, cluster, params } = opts
const { payload } = await Subtext.parse(req, null, { parse: true, output: 'data' });
const request: LensApiRequest = {
cluster: cluster,
path: url.pathname,
raw: {
req: req,
},
response: res,
query: url.searchParams,
payload: payload,
params: params
}
return request
}
protected handleStaticFile(file: string, response: http.ServerResponse) {
const asset = path.join(assetsPath, file)
readFile(asset, (err, data) => {
if (err) {
// default to index.html so that react routes work when page is refreshed
this.handleStaticFile("index.html", response)
} else {
const type = mimeTypes[path.extname(asset).slice(1)] || "text/plain";
response.setHeader("Content-Type", type);
response.write(data)
response.end()
}
})
}
protected addRoutes() {
// 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: '/api/config' }, configRoute.routeConfig.bind(configRoute))
this.router.add({ method: 'get', path: '/api/kubeconfig/service-account/{namespace}/{account}' }, kubeconfigRoute.routeServiceAccountRoute.bind(kubeconfigRoute))
// Watch API
this.router.add({ method: 'get', path: '/api/watch' }, watchRoute.routeWatch.bind(watchRoute))
// Metrics API
this.router.add({ method: 'post', path: '/api/metrics' }, metricsRoute.routeMetrics.bind(metricsRoute))
// Port-forward API
this.router.add({ method: 'post', path: '/api/services/{namespace}/{service}/port-forward/{port}' }, portForwardRoute.routeServicePortForward.bind(portForwardRoute))
// Helm API
this.router.add({ method: 'get', path: '/api-helm/v2/charts' }, helmApi.listCharts.bind(helmApi))
this.router.add({ method: 'get', path: '/api-helm/v2/charts/{repo}/{chart}' }, helmApi.getChart.bind(helmApi))
this.router.add({ method: 'get', path: '/api-helm/v2/charts/{repo}/{chart}/values' }, helmApi.getChartValues.bind(helmApi))
this.router.add({ method: 'post', path: '/api-helm/v2/releases' }, helmApi.installChart.bind(helmApi))
this.router.add({ method: 'put', path: '/api-helm/v2/releases/{namespace}/{release}' }, helmApi.updateRelease.bind(helmApi))
this.router.add({ method: 'put', path: '/api-helm/v2/releases/{namespace}/{release}/rollback' }, helmApi.rollbackRelease.bind(helmApi))
this.router.add({ method: 'get', path: '/api-helm/v2/releases/{namespace?}' }, helmApi.listReleases.bind(helmApi))
this.router.add({ method: 'get', path: '/api-helm/v2/releases/{namespace}/{release}' }, helmApi.getRelease.bind(helmApi))
this.router.add({ method: 'get', path: '/api-helm/v2/releases/{namespace}/{release}/values' }, helmApi.getReleaseValues.bind(helmApi))
this.router.add({ method: 'get', path: '/api-helm/v2/releases/{namespace}/{release}/history' }, helmApi.getReleaseHistory.bind(helmApi))
this.router.add({ method: 'delete', path: '/api-helm/v2/releases/{namespace}/{release}' }, helmApi.deleteRelease.bind(helmApi))
// Resource Applier API
this.router.add({ method: 'post', path: '/api-resource/stack' }, resourceApplierApi.applyResource.bind(resourceApplierApi))
}
}
+110
View File
@@ -0,0 +1,110 @@
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.contextHandler.kc.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 kc = cluster.contextHandler.kc
const ctx = kc.getContextObject(kc.currentContext)
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()
+58
View File
@@ -0,0 +1,58 @@
import { LensApiRequest } from "../router"
import { LensApi } from "../lens-api"
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")
return {
'apiVersion': 'v1',
'kind': 'Config',
'clusters': [
{
'name': cluster.contextName,
'cluster': {
'server': cluster.contextHandler.kc.getCurrentCluster().server,
'certificate-authority-data': secret.data["ca.crt"]
}
}
],
'users': [
{
'name': username,
'user': {
'token': tokenData.toString("utf8"),
}
}
],
'contexts': [
{
'name': cluster.contextName,
'context': {
'user': username,
'cluster': cluster.contextName,
'namespace': secret.metadata.namespace,
}
}
],
'current-context': cluster.contextName
}
}
class KubeconfigRoute extends LensApi {
public async routeServiceAccountRoute(request: LensApiRequest) {
const { params, response, cluster} = request
const client = cluster.contextHandler.kc.makeApiClient(CoreV1Api);
const secretList = await client.listNamespacedSecret(params.namespace)
const secret = secretList.body.items.find(secret => {
const { annotations } = secret.metadata;
return annotations && annotations["kubernetes.io/service-account.name"] == params.account;
});
const data = generateKubeConfig(params.account, secret, cluster);
this.respondJson(response, data)
}
}
export const kubeconfigRoute = new KubeconfigRoute()
+91
View File
@@ -0,0 +1,91 @@
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"
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}/api-kube`
const headers = {
"Host": `${cluster.id}.localhost:${cluster.port}`,
"Content-type": "application/json",
}
const queryParams: IMetricsQuery = {}
request.query.forEach((value: string, key: string) => {
queryParams[key] = value
})
let metricsUrl: 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()
} catch {
this.respondJson(response, {})
return
}
// prometheus metrics loader
const attempts: { [query: string]: number } = {};
const maxAttempts = 5;
const loadMetrics = (orgQuery: string): Promise<any> => {
const query = orgQuery.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) => {
if (attempt < maxAttempts && (error.statusCode && error.statusCode != 404)) {
await new Promise(resolve => setTimeout(resolve, attempt * 1000)); // add delay before repeating request
return loadMetrics(query);
}
return {
status: error.toString(),
data: {
result: []
}
}
})
};
// 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 {
data = {};
const result = await Promise.all(
Object.entries(query).map((queryEntry: any) => {
const queryName: string = queryEntry[0]
const queryOpts: PrometheusQueryOpts = queryEntry[1]
const queries = prometheusProvider.getQueries(queryOpts)
const q = queries[queryName as keyof (PrometheusNodeQuery | PrometheusClusterQuery | PrometheusPodQuery | PrometheusPvcQuery | PrometheusIngressQuery)]
return loadMetrics(q)
})
);
Object.keys(query).forEach((metricName, index) => {
data[metricName] = result[index];
});
}
this.respondJson(response, data)
}
}
export const metricsRoute = new MetricsRoute()
+107
View File
@@ -0,0 +1,107 @@
import { LensApiRequest } from "../router"
import { LensApi } from "../lens-api"
import { spawn, ChildProcessWithoutNullStreams } from "child_process"
import { bundledKubectl } from "../kubectl"
import { getFreePort } from "../port"
import { shell } from "electron"
import * as tcpPortUsed from "tcp-port-used"
import logger from "../logger"
class PortForward {
public static portForwards: PortForward[] = []
static getPortforward(forward: {clusterId: string; kind: string; name: string; namespace: string; port: string}) {
return PortForward.portForwards.find((pf) => {
return (
pf.clusterId == forward.clusterId &&
pf.kind == "service" &&
pf.name == forward.name &&
pf.namespace == forward.namespace &&
pf.port == forward.port
)
})
}
public clusterId: string
public process: ChildProcessWithoutNullStreams
public kubeConfig: string
public kind: string
public namespace: string
public name: string
public port: string
public localPort: number
constructor(obj: any) {
Object.assign(this, obj)
}
public async start() {
this.localPort = await getFreePort()
const kubectlBin = await bundledKubectl.kubectlPath()
const args = [
"--kubeconfig", this.kubeConfig,
"port-forward",
"-n", this.namespace,
`service/${this.name}`,
`${this.localPort}:${this.port}`
]
this.process = spawn(kubectlBin, args, {
env: process.env
})
PortForward.portForwards.push(this)
this.process.on("exit", () => {
const index = PortForward.portForwards.indexOf(this)
if (index > -1) {
PortForward.portForwards.splice(index, 1)
}
})
try {
await tcpPortUsed.waitUntilUsed(this.localPort, 500, 3000)
return true
} catch (error) {
this.process.kill()
return false
}
}
public open() {
shell.openExternal(`http://localhost:${this.localPort}`)
}
}
class PortForwardRoute extends LensApi {
public async routeServicePortForward(request: LensApiRequest) {
const { params, response, cluster} = request
let portForward = PortForward.getPortforward({
clusterId: cluster.id, kind: "service", name: params.service,
namespace: params.namespace, port: params.port
})
if (!portForward) {
logger.info(`Creating a new port-forward ${params.namespace}/${params.service}:${params.port}`)
portForward = new PortForward({
clusterId: cluster.id,
kind: "service",
namespace: params.namespace,
name: params.service,
port: params.port,
kubeConfig: cluster.kubeconfigPath()
})
const started = await portForward.start()
if (!started) {
this.respondJson(response, {
message: "Failed to open port-forward"
}, 400)
return
}
}
portForward.open()
this.respondJson(response, {})
}
}
export const portForwardRoute = new PortForwardRoute()
+105
View File
@@ -0,0 +1,105 @@
import { LensApiRequest } from "../router"
import { LensApi } from "../lens-api"
import { Watch, KubeConfig } from "@kubernetes/client-node"
import { ServerResponse } from "http"
import { Request } from "request"
import logger from "../logger"
class ApiWatcher {
private apiUrl: string
private response: ServerResponse
private watchRequest: Request
private watch: Watch
private processor: NodeJS.Timeout
private eventBuffer: any[] = []
constructor(apiUrl: string, kubeConfig: KubeConfig, response: ServerResponse) {
this.apiUrl = apiUrl
this.watch = new Watch(kubeConfig)
this.response = response
}
public async start() {
if (this.processor) {
clearInterval(this.processor)
}
this.processor = setInterval(() => {
const events = this.eventBuffer.splice(0)
events.map(event => this.sendEvent(event))
this.response.flushHeaders()
}, 50)
this.watchRequest = await this.watch.watch(this.apiUrl, {}, this.watchHandler.bind(this), this.doneHandler.bind(this))
}
public stop() {
if (!this.watchRequest) { return }
if (this.processor) {
clearInterval(this.processor)
}
logger.debug("Stopping watcher for api: " + this.apiUrl)
this.watchRequest.abort()
}
private watchHandler(phase: string, obj: any) {
this.eventBuffer.push({
type: phase,
object: obj
})
}
private doneHandler(error: Error) {
if (error) logger.warn("watch ended: " + error.toString())
this.sendEvent({
type: "STREAM_END",
url: this.apiUrl,
status: 410,
})
}
private sendEvent(evt: any) {
// convert to "text/event-stream" format
this.response.write(`data: ${JSON.stringify(evt)}\n\n`);
}
}
class WatchRoute extends LensApi {
public async routeWatch(request: LensApiRequest) {
const { params, response, cluster} = request
const apis: string[] = request.query.getAll("api")
const watchers: ApiWatcher[] = []
if (!apis.length) {
this.respondJson(response, {
message: "Empty request. Query params 'api' are not provided.",
example: "?api=/api/v1/pods&api=/api/v1/nodes",
}, 400)
return
}
response.setHeader("Content-Type", "text/event-stream")
response.setHeader("Cache-Control", "no-cache")
response.setHeader("Connection", "keep-alive")
apis.forEach(apiUrl => {
const watcher = new ApiWatcher(apiUrl, cluster.contextHandler.kc, response)
watcher.start()
watchers.push(watcher)
})
request.raw.req.on("close", () => {
logger.debug("Watch request closed")
watchers.map(watcher => watcher.stop())
})
request.raw.req.on("end", () => {
logger.debug("Watch request ended")
watchers.map(watcher => watcher.stop())
})
}
}
export const watchRoute = new WatchRoute()
+192
View File
@@ -0,0 +1,192 @@
import * as pty from "node-pty"
import * as WebSocket from "ws"
import { EventEmitter } from "events";
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 { isWindows } from "../common/vars";
export class ShellSession extends EventEmitter {
static shellEnv: any
protected websocket: WebSocket
protected shellProcess: pty.IPty
protected kubeconfigPath: string
protected nodeShellPod: string;
protected kubectl: Kubectl;
protected kubectlBinDir: string;
protected helmBinDir: string;
protected preferences: ClusterPreferences;
protected running = false;
constructor(socket: WebSocket, pathToKubeconfig: string, cluster: Cluster) {
super()
this.websocket = socket
this.kubeconfigPath = pathToKubeconfig
this.kubectl = new Kubectl(cluster.version)
this.preferences = cluster.preferences || {}
}
public async open() {
this.kubectlBinDir = await this.kubectl.binDir()
this.helmBinDir = helmCli.getBinaryDir()
const env = await this.getCachedShellEnv()
const shell = env.PTYSHELL
const args = await this.getShellArgs(shell)
this.shellProcess = pty.spawn(shell, args, {
cols: 80,
cwd: this.cwd() || env.HOME,
env: env,
name: "xterm-256color",
rows: 30,
});
this.running = true;
this.pipeStdout()
this.pipeStdin()
this.closeWebsocketOnProcessExit()
this.exitProcessOnWebsocketClose()
tracker.event("shell", "open")
}
protected cwd(): string {
if(!this.preferences || !this.preferences.terminalCWD || this.preferences.terminalCWD === "") {
return null
}
return this.preferences.terminalCWD
}
protected async getShellArgs(shell: string): Promise<Array<string>> {
switch(path.basename(shell)) {
case "powershell.exe":
return ["-NoExit", "-command", `& {Set-Location $Env:USERPROFILE; $Env:PATH="${this.kubectlBinDir};${this.helmBinDir};$Env:PATH"}`]
break
case "bash":
return ["--init-file", path.join(this.kubectlBinDir, '.bash_set_path')]
break
case "fish":
return ["--login", "--init-command", `export PATH="${this.kubectlBinDir}:${this.helmBinDir}:$PATH"; export KUBECONFIG="${this.kubeconfigPath}"`]
break
case "zsh":
return ["--login"]
default:
return []
}
}
protected async getCachedShellEnv() {
let env: any
if (!ShellSession.shellEnv) {
env = await this.getShellEnv()
ShellSession.shellEnv = env
} else {
env = ShellSession.shellEnv
// refresh env in the background
this.getShellEnv().then((shellEnv: any) => {
ShellSession.shellEnv = shellEnv
})
}
return env
}
protected async getShellEnv() {
const env = JSON.parse(JSON.stringify(await shellEnv()))
const pathStr = [this.kubectlBinDir, this.helmBinDir, process.env.PATH].join(path.delimiter)
if(isWindows) {
env["SystemRoot"] = process.env.SystemRoot
env["PTYSHELL"] = "powershell.exe"
env["PATH"] = pathStr
} else if(typeof(process.env.SHELL) != "undefined") {
env["PTYSHELL"] = process.env.SHELL
env["PATH"] = pathStr
} else {
env["PTYSHELL"] = "" // blank runs the system default shell
}
if(path.basename(env["PTYSHELL"]) === "zsh") {
env["OLD_ZDOTDIR"] = env.ZDOTDIR || env.HOME
env["ZDOTDIR"] = this.kubectlBinDir
}
env["PTYPID"] = process.pid.toString()
env["KUBECONFIG"] = this.kubeconfigPath
env["TERM_PROGRAM"] = app.getName()
env["TERM_PROGRAM_VERSION"] = app.getVersion()
if (this.preferences.httpsProxy) {
env["HTTPS_PROXY"] = this.preferences.httpsProxy
}
if (env.DEBUG) { // do not pass debug option to bash
delete env["DEBUG"]
}
return(env)
}
protected pipeStdout() {
// send shell output to websocket
this.shellProcess.on("data", ((data: string) => {
this.sendResponse(data)
}).bind(this));
}
protected pipeStdin() {
// write websocket messages to shellProcess
this.websocket.on("message", function(data: string) {
if (!this.running) { return }
const message = Buffer.from(data.slice(1, data.length), "base64").toString()
switch (data[0]) {
case "0":
this.shellProcess.write(message)
break;
case "4":
const resizeMsgObj = JSON.parse(message)
this.shellProcess.resize(resizeMsgObj["Width"], resizeMsgObj["Height"])
break;
case "9":
this.token = message
this.emit('newToken', this.token)
break;
}
}.bind(this))
}
protected exit(code = 1000) {
this.websocket.close(code)
this.emit('exit')
}
protected closeWebsocketOnProcessExit() {
this.shellProcess.on("exit", (code) => {
this.running = false
let timeout = 0
if (code > 0) {
this.sendResponse("Terminal will auto-close in 15 seconds ...")
timeout = 15*1000
}
setTimeout(() => {
this.exit()
}, timeout)
});
}
protected exitProcessOnWebsocketClose() {
this.websocket.on("close", () => {
if (this.shellProcess) {
this.shellProcess.kill();
}
})
}
protected sendResponse(msg: string) {
this.websocket.send("1" + Buffer.from(msg).toString("base64"))
}
}
+19
View File
@@ -0,0 +1,19 @@
import shellEnv from "shell-env"
import logger from "./logger"
import { isMac, isProduction } from "../common/vars";
export async function shellSync() {
const env = await shellEnv()
// Overwrite PATH on darwin
if (isProduction && isMac) {
process.env["PATH"] = env.PATH
}
let key = null
for (key in env) {
if (!env.hasOwnProperty(key) || process.env[key]) continue // skip existing and prototype keys
logger.debug("Imported " + key + " from login shell to process environment")
process.env[key] = env[key]
}
}
+4
View File
@@ -0,0 +1,4 @@
import { Tracker } from "../common/tracker"
import { app, remote } from "electron"
export const tracker = new Tracker(app || remote.app);
+7
View File
@@ -0,0 +1,7 @@
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");
}
+82
View File
@@ -0,0 +1,82 @@
import path from "path"
import { BrowserWindow, shell } from "electron"
import { PromiseIpc } from "electron-promise-ipc"
import windowStateKeeper from "electron-window-state"
import { tracker } from "./tracker";
import { isDevelopment } from "../common/vars";
export class WindowManager {
public mainWindow: BrowserWindow = null;
public splashWindow: BrowserWindow = null;
protected promiseIpc: any
protected windowState: windowStateKeeper.State;
constructor(showSplash = true) {
this.promiseIpc = new PromiseIpc({ timeout: 2000 })
// Manage main window size&position with persistence
this.windowState = windowStateKeeper({
defaultHeight: 900,
defaultWidth: 1440,
});
this.splashWindow = new BrowserWindow({
width: 500,
height: 300,
backgroundColor: "#1e2124",
center: true,
frame: false,
resizable: false,
show: false
})
if (showSplash) {
this.splashWindow.loadFile(path.join(__static, "/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",
titleBarStyle: "hidden",
webPreferences: {
devTools: isDevelopment,
nodeIntegration: true,
webviewTag: true
},
});
// Hook window state manager into window lifecycle
this.windowState.manage(this.mainWindow);
// handle close event
this.mainWindow.on("close", () => {
this.mainWindow = null;
})
// handle external links
this.mainWindow.webContents.on("will-navigate", (event, link) => {
if (link.startsWith("http://localhost")) {
return;
}
event.preventDefault();
shell.openExternal(link);
})
this.mainWindow.on("focus", () => {
tracker.event("app", "focus")
})
}
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()
})
})
}
}