-
Notifications
You must be signed in to change notification settings - Fork 138
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add kubeconfig watcher and proxy service
- Loading branch information
1 parent
dfe03a6
commit 23b6c09
Showing
35 changed files
with
1,038 additions
and
37 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
105 changes: 105 additions & 0 deletions
105
electron/app/services/cluster/daemons/kubeconfigWatcher.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,105 @@ | ||
import * as k8s from '@kubernetes/client-node'; | ||
|
||
import {BrowserWindow} from 'electron'; | ||
|
||
import {FSWatcher, watch} from 'chokidar'; | ||
import fs from 'fs/promises'; | ||
import {uniq} from 'lodash'; | ||
|
||
import {ModernKubeConfig} from '@shared/models/config'; | ||
|
||
import {getDefaultKubeConfig} from '../utils/getDefaultKubeConfig'; | ||
|
||
// TODO: validate whether this only dispatches when there are | ||
// no changes to avoid unnecessary dispatches in renderer. | ||
// TODO: Dispatch delete event so slices can remove stale kubeconfigs. | ||
export class KubeConfigWatcher { | ||
private watchers: Map<string, FSWatcher> = new Map(); | ||
|
||
async watch(kubeconfigs: string[]): Promise<void> { | ||
const newKubeconfigs = uniq([getDefaultKubeConfig(), ...kubeconfigs]); | ||
const oldKubeconfigs = [...this.watchers.keys()]; | ||
|
||
// Delete removed watchers. | ||
for (const kubeconfig of oldKubeconfigs) { | ||
const shouldDelete = !newKubeconfigs.includes(kubeconfig); | ||
|
||
if (shouldDelete) { | ||
await this.stopOne(kubeconfig); | ||
} | ||
} | ||
|
||
// Create added watchers. | ||
for (const kubeconfig of newKubeconfigs) { | ||
const shouldAdd = !oldKubeconfigs.includes(kubeconfig); | ||
|
||
if (shouldAdd) { | ||
await this.watchFile(kubeconfig); | ||
} | ||
} | ||
} | ||
|
||
private async watchFile(kubeconfigPath: string) { | ||
try { | ||
const stats = await fs.stat(kubeconfigPath); | ||
|
||
if (!stats.isFile()) { | ||
return; | ||
} | ||
|
||
const watcher = watch(kubeconfigPath, { | ||
persistent: true, | ||
usePolling: true, | ||
interval: 1000, | ||
ignoreInitial: true, | ||
}); | ||
|
||
this.watchers.set(kubeconfigPath, watcher); | ||
|
||
watcher?.on('all', (type: string) => { | ||
if (type === 'unlink') { | ||
watcher?.close(); | ||
process.parentPort.postMessage({type: 'config/setKubeConfig', payload: undefined}); | ||
return; | ||
} | ||
|
||
const config = new k8s.KubeConfig(); | ||
config.loadFromFile(kubeconfigPath); | ||
this.broadcast(kubeconfigPath, config); | ||
}); | ||
|
||
// Run once manually to get started | ||
const config = new k8s.KubeConfig(); | ||
config.loadFromFile(kubeconfigPath); | ||
this.broadcast(kubeconfigPath, config); | ||
} catch (e: any) { | ||
// eslint-disable-next-line no-console | ||
console.log('monitorKubeConfigError', e.message); | ||
} | ||
} | ||
|
||
private broadcast(kubeconfigPath: string, config: k8s.KubeConfig) { | ||
const kc: ModernKubeConfig = { | ||
path: kubeconfigPath, | ||
currentContext: config.getCurrentContext(), | ||
contexts: config.getContexts(), | ||
clusters: config.getClusters(), | ||
users: config.getUsers(), | ||
}; | ||
|
||
BrowserWindow.getAllWindows().forEach(w => w.webContents.send('kubeconfig:update', kc)); | ||
} | ||
|
||
async stop(): Promise<void> { | ||
for (const kubeconfig of this.watchers.keys()) { | ||
await this.stopOne(kubeconfig); | ||
} | ||
} | ||
|
||
private async stopOne(kubeconfig: string): Promise<void> { | ||
const watcher = this.watchers.get(kubeconfig); | ||
if (!watcher) return; | ||
await watcher.close(); | ||
this.watchers.delete(kubeconfig); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
import type {ContextId, MonokleClusterError} from '@shared/ipc'; | ||
|
||
const errors = createErrors({ | ||
unknown: { | ||
title: 'Cannot connect to the cluster', | ||
description: 'An unknown problem occurred. Consider taking a peek at the debug logs.', | ||
learnMoreUrl: 'https://kubeshop.github.io/monokle/cluster-issues/', | ||
}, | ||
'proxy-timeout': { | ||
title: 'Cannot connect to the cluster', | ||
description: 'The proxy could not be started.', | ||
}, | ||
'local-connection-refused': { | ||
title: 'Cannot connect to the cluster', | ||
description: 'The connection was refused - is your Docker Engine or VM running?', | ||
}, | ||
'k8s-timeout': { | ||
title: 'Cannot connect to the cluster', | ||
description: 'The request timed out. Consider checking your firewall and server if the problem persists.', | ||
}, | ||
'openssl-problem': { | ||
title: 'Cannot connect to the cluster', | ||
description: | ||
'A problem occurred in OpenSSL. Consider checking the certificate of your cluster if the problem persists.', | ||
}, | ||
'k8s-unauthenticated': { | ||
title: 'Cannot connect to the cluster', | ||
description: 'The Kubernetes API server expects authentication or is misconfigured.', | ||
}, | ||
'gcp-legacy-plugin': { | ||
title: 'Cannot authenticate to the cluster', | ||
description: 'The GCP auth plugin is deprecated and needs to be updated.', | ||
learnMoreUrl: 'https://cloud.google.com/blog/products/containers-kubernetes/kubectl-auth-changes-in-gke', | ||
}, | ||
'gcp-gcloud-unauthenticated': { | ||
title: 'Cannot authenticate to the cluster', | ||
description: | ||
'A problem occurred in gcloud. Consider to re-authenticate, refetch cluster credentials and try again.', | ||
}, | ||
'aws-sso-expired': { | ||
title: 'Cannot authenticate to the cluster', | ||
description: | ||
"Your AWS SSO sesion has expired or became invalid. Consider to re-authenticate with 'aws sso login' and try again.", | ||
}, | ||
}); | ||
|
||
export type MonokleClusterErrorCode = keyof typeof errors; | ||
|
||
export function getMonokleClusterError(code: MonokleClusterErrorCode, contextId?: ContextId): MonokleClusterError { | ||
return { | ||
code, | ||
...errors[code], | ||
...contextId, | ||
}; | ||
} | ||
|
||
function createErrors<TCode extends string>( | ||
init: Record<TCode, Omit<MonokleClusterError, 'code'>> | ||
): Record<TCode, Omit<MonokleClusterError, 'code'>> { | ||
return init; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
import {ProxyService} from '../../../kubernetes/ProxyService'; | ||
import {KubeConfigWatcher} from './daemons/kubeconfigWatcher'; | ||
|
||
export const PROXY_SERVICE = new ProxyService(); | ||
export const KUBE_CONFIG_WATCHER = new KubeConfigWatcher(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
import {PROXY_SERVICE} from '@root/electron/app/services/cluster/globals'; | ||
import {DebugProxyArgs, DebugProxyResponse} from '@shared/ipc'; | ||
|
||
import {setup} from './setup'; | ||
|
||
export async function debugProxy({context, kubeconfig}: DebugProxyArgs): Promise<DebugProxyResponse> { | ||
try { | ||
const proxy = await PROXY_SERVICE.get(context, kubeconfig); | ||
return proxy.debugInfo; | ||
} catch (err) { | ||
// This means that either we never pinged or the proxy failed to boot. | ||
const result = await setup({context, kubeconfig}); | ||
|
||
if (result.success) { | ||
const proxy = await PROXY_SERVICE.get(context, kubeconfig); | ||
return proxy.debugInfo; | ||
} | ||
|
||
return { | ||
cmd: 'kubectl proxy exitted', | ||
logs: [ | ||
{ | ||
type: 'stderr', | ||
timestamp: Date.now(), | ||
content: result.title, | ||
}, | ||
], | ||
}; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,4 @@ | ||
export * from './ping'; | ||
export * from './setup'; | ||
export * from './debugProxy'; | ||
export * from './kubeconfig/get'; | ||
export * from './kubeconfig/watch'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
import * as k8s from '@kubernetes/client-node'; | ||
|
||
import {ModernKubeConfig} from '@shared/models/config'; | ||
|
||
import {getDefaultKubeConfig} from '../../utils/getDefaultKubeConfig'; | ||
|
||
type KubeConfigGet = { | ||
path: string | undefined; | ||
}; | ||
|
||
export async function getKubeConfig(options: KubeConfigGet): Promise<ModernKubeConfig> { | ||
const path = options.path ?? getDefaultKubeConfig(); | ||
const config = new k8s.KubeConfig(); | ||
config.loadFromFile(path); | ||
|
||
return { | ||
path, | ||
currentContext: config.getCurrentContext(), | ||
contexts: config.getContexts(), | ||
clusters: config.getClusters(), | ||
users: config.getUsers(), | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
import {KUBE_CONFIG_WATCHER} from '../../globals'; | ||
|
||
export function watchKubeconfig(params: {kubeconfigs: string[]}) { | ||
return KUBE_CONFIG_WATCHER.watch(params.kubeconfigs); | ||
} | ||
|
||
export function stopWatchingKubeconfig() { | ||
KUBE_CONFIG_WATCHER.stop(); | ||
} |
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
import fetch from 'node-fetch'; | ||
|
||
import type {ContextId, MonokleClusterError, SetupParams, SetupResult} from '@shared/ipc'; | ||
|
||
import {getMonokleClusterError} from '../errors'; | ||
import {PROXY_SERVICE} from '../globals'; | ||
|
||
export async function setup({context, kubeconfig, skipHealthCheck}: SetupParams): Promise<SetupResult> { | ||
try { | ||
const proxy = await PROXY_SERVICE.get(context, kubeconfig); | ||
|
||
if (!skipHealthCheck) { | ||
await doHealthCheck(proxy.port); | ||
} | ||
|
||
return {success: true, port: proxy.port}; | ||
} catch (err) { | ||
const msg = err instanceof Error ? err.message : 'Cannot access cluster'; | ||
const clusterError = determineError(msg, {context, kubeconfig}); | ||
return { | ||
success: false, | ||
...clusterError, | ||
}; | ||
} | ||
} | ||
|
||
async function doHealthCheck(port: number): Promise<boolean> { | ||
const response = await fetch(`http://127.0.0.1:${port}/api`); | ||
|
||
if (response.ok) return true; | ||
|
||
const reason = await response.text(); | ||
throw new Error(reason); | ||
} | ||
|
||
function determineError(reason: string, contextId: ContextId): MonokleClusterError { | ||
// Monokle Proxy startup errors. | ||
// These happen locally while spawning the kube-proxy. | ||
// Most often inferred within our ProxyInstance.ts. | ||
if (reason.includes('MONOKLE_PROXY_GCP_LEGACY_PLUGIN')) { | ||
return getMonokleClusterError('gcp-legacy-plugin', contextId); | ||
} | ||
if (reason.includes('MONOKLE_PROXY_TIMEOUT')) { | ||
return getMonokleClusterError('proxy-timeout', contextId); | ||
} | ||
|
||
// Kubectl user authentication error. | ||
// These happen within the local kube-proxy. | ||
if (reason.includes('getting credentials: exec')) { | ||
if (reason.includes('gke-gcloud-auth-plugin')) { | ||
return getMonokleClusterError('gcp-gcloud-unauthenticated', contextId); | ||
} | ||
if (reason.includes('aws')) { | ||
return getMonokleClusterError('k8s-unauthenticated', contextId); | ||
} | ||
} | ||
if (reason.includes('Failed to retrieve access token') && reason.includes('gcloud')) { | ||
return getMonokleClusterError('gcp-gcloud-unauthenticated', contextId); | ||
} | ||
|
||
// Networking / OS errors between proxy and API server. | ||
// These can happen when a socket hangs up or a port refuses a connection. | ||
const isLocal = reason.includes('127.0.0.1') || reason.includes('0.0.0.0'); | ||
const isRefused = reason.includes('connect: connection refused') || reason.includes('ECONNREFUSED'); | ||
if (isLocal && isRefused) { | ||
return getMonokleClusterError('local-connection-refused', contextId); | ||
} | ||
if (reason.includes('ETIMEDOUT')) { | ||
return getMonokleClusterError('k8s-timeout', contextId); | ||
} | ||
if (reason.includes('OPENSSL')) { | ||
return getMonokleClusterError('openssl-problem', contextId); | ||
} | ||
|
||
if (reason.includes('"kind":"Status"') && reason.includes('401')) { | ||
// Kubernetes API server errors. | ||
// These happen within the remote server. | ||
return getMonokleClusterError('k8s-unauthenticated', contextId); | ||
} | ||
|
||
return getMonokleClusterError('unknown', contextId); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,11 @@ | ||
import {handleIpc} from '../../utils/ipc'; | ||
import {ping} from './handlers/ping'; | ||
import {debugProxy, getKubeConfig, setup, stopWatchingKubeconfig, watchKubeconfig} from './handlers'; | ||
|
||
handleIpc('cluster:ping', ping); | ||
// Cluster & Proxy management | ||
handleIpc('cluster:setup', setup); | ||
handleIpc('cluster:debug-proxy', debugProxy); | ||
|
||
// Kubeconfig management | ||
handleIpc('kubeconfig:get', getKubeConfig); | ||
handleIpc('kubeconfig:watch', watchKubeconfig); | ||
handleIpc('kubeconfig:watch:stop', stopWatchingKubeconfig); |
11 changes: 11 additions & 0 deletions
11
electron/app/services/cluster/utils/getDefaultKubeConfig.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
import {app} from 'electron'; | ||
|
||
import path from 'path'; | ||
|
||
export function getDefaultKubeConfig() { | ||
// TODO: use KUBECONFIG env as override to this default. | ||
|
||
const home = app.getPath('home'); | ||
const kubeConfigPath = path.join(home, `${path.sep}.kube${path.sep}config`); | ||
return kubeConfigPath; | ||
} |
Oops, something went wrong.