Skip to content

Commit

Permalink
feat: add kubeconfig watcher and proxy service
Browse files Browse the repository at this point in the history
  • Loading branch information
WitoDelnat committed Apr 17, 2023
1 parent dfe03a6 commit 23b6c09
Show file tree
Hide file tree
Showing 35 changed files with 1,038 additions and 37 deletions.
1 change: 1 addition & 0 deletions electron/app/ipc/ipcListeners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
} from '../commands';
import {killKubectlProxyProcess, startKubectlProxyProcess} from '../kubectl';
import {ProjectNameChange, StorePropagation} from '../models';
import '../services/cluster/ipc';
import {downloadPlugin, updatePlugin} from '../services/pluginService';
import {
downloadTemplate,
Expand Down
4 changes: 3 additions & 1 deletion electron/app/openApplication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {logToFile} from '@shared/utils/logs';

import {createWindow} from './createWindow';
import {getDockMenu} from './menu';
import {PROXY_SERVICE} from './services/cluster/globals';

Object.assign(console, logToFile.functions);

Expand Down Expand Up @@ -68,7 +69,8 @@ export const openApplication = async (givenPath?: string) => {
globalShortcut.unregister('CommandOrControl+Shift+R');
});

app.on('quit', (event, exitCode) => {
app.on('quit', (_event, exitCode) => {
PROXY_SERVICE.stopAll();
trackEvent('APP_QUIT', {exitCode});
});
};
105 changes: 105 additions & 0 deletions electron/app/services/cluster/daemons/kubeconfigWatcher.ts
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);
}
}
61 changes: 61 additions & 0 deletions electron/app/services/cluster/errors.ts
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;
}
5 changes: 5 additions & 0 deletions electron/app/services/cluster/globals.ts
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();
30 changes: 30 additions & 0 deletions electron/app/services/cluster/handlers/debugProxy.ts
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,
},
],
};
}
}
5 changes: 4 additions & 1 deletion electron/app/services/cluster/handlers/index.ts
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';
23 changes: 23 additions & 0 deletions electron/app/services/cluster/handlers/kubeconfig/get.ts
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(),
};
}
9 changes: 9 additions & 0 deletions electron/app/services/cluster/handlers/kubeconfig/watch.ts
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();
}
7 changes: 0 additions & 7 deletions electron/app/services/cluster/handlers/ping.ts

This file was deleted.

82 changes: 82 additions & 0 deletions electron/app/services/cluster/handlers/setup.ts
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);
}
11 changes: 9 additions & 2 deletions electron/app/services/cluster/ipc.ts
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 electron/app/services/cluster/utils/getDefaultKubeConfig.ts
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;
}
Loading

0 comments on commit 23b6c09

Please sign in to comment.