From 141ba19c1bbc9c482828e2b681ba8d7cf1ee11a7 Mon Sep 17 00:00:00 2001 From: Josh Pinkney Date: Tue, 21 Dec 2021 16:19:15 -0500 Subject: [PATCH] [feat] Implement editor agnostic kubeconfig injection Signed-off-by: Josh Pinkney --- .../src/api/kubeConfigAPI.ts | 37 ++++ .../src/devworkspace-client/index.ts | 8 + .../services/api/kubeConfigApi.ts | 200 ++++++++++++++++++ .../services/helpers/stream.ts | 31 +++ .../src/devworkspace-client/types/index.ts | 8 + packages/dashboard-backend/src/index.ts | 3 + .../dashboard-backend/src/typings/models.d.ts | 4 + .../src/containers/FactoryLoader/index.tsx | 8 + .../src/containers/IdeLoader.tsx | 8 + .../devWorkspaceApi.ts | 10 + 10 files changed, 317 insertions(+) create mode 100644 packages/dashboard-backend/src/api/kubeConfigAPI.ts create mode 100644 packages/dashboard-backend/src/devworkspace-client/services/api/kubeConfigApi.ts create mode 100644 packages/dashboard-backend/src/devworkspace-client/services/helpers/stream.ts diff --git a/packages/dashboard-backend/src/api/kubeConfigAPI.ts b/packages/dashboard-backend/src/api/kubeConfigAPI.ts new file mode 100644 index 0000000000..23d2722931 --- /dev/null +++ b/packages/dashboard-backend/src/api/kubeConfigAPI.ts @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2018-2021 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { baseApiPath } from '../constants/config'; +import { getDevWorkspaceClient } from './helper'; +import { getSchema } from '../services/helpers'; +import { restParams } from '../typings/models'; + +const tags = ['kubeconfig']; + +export function registerKubeConfigApi(server: FastifyInstance) { + server.post( + `${baseApiPath}/namespace/:namespace/devworkspaceId/:devworkspaceId/kubeconfig`, + getSchema({ tags }), + async function (request: FastifyRequest, reply: FastifyReply) { + try { + const { kubeConfigApi } = await getDevWorkspaceClient(request); + const { namespace, devworkspaceId } = request.params as restParams.INamespacedPodParam; + await kubeConfigApi.injectKubeConfig(namespace, devworkspaceId); + reply.code(204); + return reply.send(); + } catch (e) { + return reply.send(e); + } + }, + ); +} diff --git a/packages/dashboard-backend/src/devworkspace-client/index.ts b/packages/dashboard-backend/src/devworkspace-client/index.ts index b4953f285b..7ef254fd22 100644 --- a/packages/dashboard-backend/src/devworkspace-client/index.ts +++ b/packages/dashboard-backend/src/devworkspace-client/index.ts @@ -17,6 +17,7 @@ import { IDevWorkspaceClient, IDevWorkspaceTemplateApi, IDockerConfigApi, + IKubeConfigApi, } from './types'; import { findApi } from './services/helpers'; import { DevWorkspaceTemplateApi } from './services/api/template-api'; @@ -24,6 +25,7 @@ import { DevWorkspaceApi } from './services/api/workspace-api'; import { devworkspaceGroup, devworkspaceLatestVersion } from '@devfile/api'; import { DockerConfigApi } from './services/api/dockerConfigApi'; import { ServerConfigApi } from './services/api/serverConfigApi'; +import { KubeConfigAPI } from './services/api/kubeConfigApi'; export * from './types'; @@ -35,12 +37,14 @@ export class DevWorkspaceClient implements IDevWorkspaceClient { private readonly _devworkspaceApi: IDevWorkspaceApi; private readonly _dockerConfigApi: IDockerConfigApi; private readonly _serverConfigApi: IServerConfigApi; + private readonly _kubeConfigApi: IKubeConfigApi; constructor(kc: k8s.KubeConfig) { this._templateApi = new DevWorkspaceTemplateApi(kc); this._devworkspaceApi = new DevWorkspaceApi(kc); this._dockerConfigApi = new DockerConfigApi(kc); this._serverConfigApi = new ServerConfigApi(kc); + this._kubeConfigApi = new KubeConfigAPI(kc); this._apisApi = kc.makeApiClient(k8s.ApisApi); } @@ -60,6 +64,10 @@ export class DevWorkspaceClient implements IDevWorkspaceClient { return this._serverConfigApi; } + get kubeConfigApi(): IKubeConfigApi { + return this._kubeConfigApi; + } + async isDevWorkspaceApiEnabled(): Promise { if (this.apiEnabled !== undefined) { return Promise.resolve(this.apiEnabled); diff --git a/packages/dashboard-backend/src/devworkspace-client/services/api/kubeConfigApi.ts b/packages/dashboard-backend/src/devworkspace-client/services/api/kubeConfigApi.ts new file mode 100644 index 0000000000..dd10973422 --- /dev/null +++ b/packages/dashboard-backend/src/devworkspace-client/services/api/kubeConfigApi.ts @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2018-2021 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import * as k8s from '@kubernetes/client-node'; +import { IKubeConfigApi } from '../../types'; +import { StdStream } from '../helpers/stream'; + +export class KubeConfigAPI implements IKubeConfigApi { + private readonly execAPI: k8s.Exec; + private readonly corev1API: k8s.CoreV1Api; + private readonly kubeConfig: string; + + constructor(kc: k8s.KubeConfig) { + this.execAPI = new k8s.Exec(kc); + this.corev1API = kc.makeApiClient(k8s.CoreV1Api); + this.kubeConfig = kc.exportConfig(); + } + + /** + * Inject the kubeconfig into all containers with the given name in the namespace + * @param namespace The namespace where the pod lives + * @param devworkspaceId The id of the devworkspace + */ + async injectKubeConfig(namespace: string, devworkspaceId: string): Promise { + const currentPod = await this.getPodByDevWorkspaceId(namespace, devworkspaceId); + const podName = currentPod.metadata?.name || ''; + const currentPodContainers = currentPod.spec?.containers || []; + + try { + for (const container of currentPodContainers) { + const containerName = container.name; + + // find the directory where we should create the kubeconfig + const kubeConfigDirectory = await this.resolveDirectory(podName, namespace, containerName); + if (kubeConfigDirectory === '') { + console.log( + `Could not find appropriate kubeconfig directory for ${namespace}/${podName}/${containerName}`, + ); + continue; + } + + // then create the directory if it doesn't exist + await this.exec(podName, namespace, containerName, [ + 'sh', + '-c', + `mkdir -p ${kubeConfigDirectory}`, + ]); + + // if -f ${kubeConfigDirectory}/config is not found then sync kubeconfig to the container + await this.exec(podName, namespace, containerName, [ + 'sh', + '-c', + `[ -f ${kubeConfigDirectory}/config ] || echo '${this.kubeConfig}' > ${kubeConfigDirectory}/config`, + ]); + } + } catch (e) { + throw e; + } + } + + /** + * Given a namespace, find a pod that has the label controller.devfile.io/devworkspace_id=${devworkspaceId} + * @param namespace The namespace to look in + * @param devworkspaceId The id of the devworkspace + * @returns The containers for the first pod with given devworkspaceId + */ + private async getPodByDevWorkspaceId( + namespace: string, + devworkspaceId: string, + ): Promise { + try { + const resp = await this.corev1API.listNamespacedPod( + namespace, + undefined, + false, + undefined, + undefined, + `controller.devfile.io/devworkspace_id=${devworkspaceId}`, + ); + if (resp.body.items.length === 0) { + throw new Error( + `Could not find requested devworkspace with id ${devworkspaceId} in ${namespace}`, + ); + } + return resp.body.items[0]; + } catch (e: any) { + throw new Error(`Error occured when attempting to retrieve pod. ${e.message}`); + } + } + + /** + * Resolve the directory where the kubeconfig is going to live. First it looks for the $KUBECONFIG env variable if + * that is found then use that. If that is not found then the default directory is $HOME/.kube + * @param name The name of the pod + * @param namespace The namespace where the pod lives + * @param containerName The name of the container to resolve the directory for + * @returns A promise of the directory where the kubeconfig is going to live + */ + private async resolveDirectory( + name: string, + namespace: string, + containerName: string, + ): Promise { + try { + // attempt to resolve the kubeconfig env variable + const kubeConfigEnvResolver = await this.exec(name, namespace, containerName, [ + 'sh', + '-c', + 'echo $KUBECONFIG', + ]); + + if (kubeConfigEnvResolver.stdOut !== '') { + return kubeConfigEnvResolver.stdOut.replace(new RegExp('/config$'), ''); + } + + // attempt to resolve the home directory + const homeEnvResolution = await this.exec(name, namespace, containerName, [ + 'sh', + '-c', + 'echo $HOME', + ]); + + if (homeEnvResolution.stdOut !== '' && homeEnvResolution.stdOut !== '/') { + if (homeEnvResolution.stdOut.substr(-1) === '/') { + return homeEnvResolution.stdOut + '.kube'; + } else { + return homeEnvResolution.stdOut + '/.kube'; + } + } + return ''; + } catch (e) { + throw e; + } + } + + /** + * Execute the given command inside of a given container in a pod with a name and namespace and return the + * stdout and stderr responses + * @param name The name of the pod + * @param namespace The namespace where the pod lives + * @param containerName The name of the container + * @param command The command to return + * @returns The strings containing the stdout and stderr of running the command in the container + */ + private async exec( + name: string, + namespace: string, + containerName: string, + command: string[], + ): Promise<{ stdOut: string }> { + try { + const stdOutStream = new StdStream(); + + // Wait until the exec request is done and reject if the final status is a failure, otherwise + // everything went OK and stdOutStream contains the response + await new Promise((resolve, reject) => { + this.execAPI.exec( + namespace, + name, + containerName, + command, + stdOutStream, + null, + null, + true, + status => { + if (status.status === 'Failure') { + reject(status); + } else { + resolve(status); + } + }, + ); + }); + return { + stdOut: stdOutStream.chunks, + }; + } catch (e: any) { + // swallow this error message and log it out instead because not all containers have a shell that you can use to inject + // and will fail by default + console.log( + `Failed trying to run command: ${command.join( + ' ', + )} in ${namespace}/${name}/${containerName} with message: ${e.message}`, + ); + return { + stdOut: '', + }; + } + } +} diff --git a/packages/dashboard-backend/src/devworkspace-client/services/helpers/stream.ts b/packages/dashboard-backend/src/devworkspace-client/services/helpers/stream.ts new file mode 100644 index 0000000000..aa0c09af9e --- /dev/null +++ b/packages/dashboard-backend/src/devworkspace-client/services/helpers/stream.ts @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2018-2021 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { Writable } from 'stream'; + +export class StdStream extends Writable { + private _chunks; + + constructor() { + super(); + this._chunks = ''; + } + + _write(chunk: string, encoding: BufferEncoding, done: (error?: Error | null) => void): void { + this._chunks += chunk.toString(); + done(); + } + + get chunks() { + return this._chunks.trim(); + } +} diff --git a/packages/dashboard-backend/src/devworkspace-client/types/index.ts b/packages/dashboard-backend/src/devworkspace-client/types/index.ts index 6d1e965920..153ec30190 100644 --- a/packages/dashboard-backend/src/devworkspace-client/types/index.ts +++ b/packages/dashboard-backend/src/devworkspace-client/types/index.ts @@ -92,6 +92,13 @@ export interface IServerConfigApi { getDefaultPlugins(): Promise; } +export interface IKubeConfigApi { + /** + * Inject the kubeconfig into all containers with the given devworkspaceId in a namespace + */ + injectKubeConfig(namespace: string, devworkspaceId: string): Promise; +} + export type IDevWorkspaceCallbacks = { onModified: (workspace: V1alpha2DevWorkspace) => void; onDeleted: (workspaceId: string) => void; @@ -104,6 +111,7 @@ export interface IDevWorkspaceClient { templateApi: IDevWorkspaceTemplateApi; dockerConfigApi: IDockerConfigApi; serverConfigApi: IServerConfigApi; + kubeConfigApi: IKubeConfigApi; isDevWorkspaceApiEnabled(): Promise; } diff --git a/packages/dashboard-backend/src/index.ts b/packages/dashboard-backend/src/index.ts index eff32528e4..2f50083ef6 100644 --- a/packages/dashboard-backend/src/index.ts +++ b/packages/dashboard-backend/src/index.ts @@ -26,6 +26,7 @@ import { registerClusterInfo } from './api/clusterInfo'; import { CLUSTER_CONSOLE_URL } from './devworkspace-client/services/cluster-info'; import { registerDockerConfigApi } from './api/dockerConfigApi'; import { registerServerConfigApi } from './api/serverConfigApi'; +import { registerKubeConfigApi } from './api/kubeConfigAPI'; const CHE_HOST = process.env.CHE_HOST as string; @@ -70,6 +71,8 @@ registerDockerConfigApi(server); registerServerConfigApi(server); +registerKubeConfigApi(server); + if (CLUSTER_CONSOLE_URL) { registerClusterInfo(server); } diff --git a/packages/dashboard-backend/src/typings/models.d.ts b/packages/dashboard-backend/src/typings/models.d.ts index eee8b074d0..635a374eac 100644 --- a/packages/dashboard-backend/src/typings/models.d.ts +++ b/packages/dashboard-backend/src/typings/models.d.ts @@ -31,6 +31,10 @@ declare namespace restParams { templateName: string; } + export interface INamespacedPodParam extends INamespacedParam { + devworkspaceId: string; + } + export interface IStatusUpdate { error?: string; message?: string; diff --git a/packages/dashboard-frontend/src/containers/FactoryLoader/index.tsx b/packages/dashboard-frontend/src/containers/FactoryLoader/index.tsx index a551e8a1e8..ffc38aaec0 100644 --- a/packages/dashboard-frontend/src/containers/FactoryLoader/index.tsx +++ b/packages/dashboard-frontend/src/containers/FactoryLoader/index.tsx @@ -47,6 +47,7 @@ import { DEVWORKSPACE_DEVFILE_SOURCE } from '../../services/workspace-client/dev import devfileApi from '../../services/devfileApi'; import getRandomString from '../../services/helpers/random'; import { isDevworkspacesEnabled } from '../../services/helpers/devworkspace'; +import { injectKubeConfig } from '../../services/dashboard-backend-client/devWorkspaceApi'; const WS_ATTRIBUTES_TO_SAVE: string[] = [ 'workspaceDeploymentLabels', @@ -194,6 +195,13 @@ export class FactoryLoaderContainer extends React.PureComponent { this.showAlert(`Getting workspace detail data failed. ${e}`); } await delay(); + try { + if (workspace.isDevWorkspace) { + await injectKubeConfig(workspace.namespace, workspace.id); + } + } catch (e) { + this.showAlert(`Injecting kubeconfig failed. ${e}`); + } history.push(buildIdeLoaderLocation(workspace)); } diff --git a/packages/dashboard-frontend/src/containers/IdeLoader.tsx b/packages/dashboard-frontend/src/containers/IdeLoader.tsx index 1abdacdfa0..dcb0742450 100644 --- a/packages/dashboard-frontend/src/containers/IdeLoader.tsx +++ b/packages/dashboard-frontend/src/containers/IdeLoader.tsx @@ -34,6 +34,7 @@ import { buildWorkspacesLocation } from '../services/helpers/location'; import { DisposableCollection } from '../services/helpers/disposable'; import { Workspace } from '../services/workspace-adapter'; import { selectDevworkspacesEnabled } from '../store/Workspaces/Settings/selectors'; +import { injectKubeConfig } from '../services/dashboard-backend-client/devWorkspaceApi'; type Props = MappedProps & { history: History } & RouteComponentProps<{ namespace: string; @@ -422,6 +423,13 @@ class IdeLoaderContainer extends React.PureComponent { } const workspace = this.props.allWorkspaces.find(workspace => workspace.id === cheWorkspace.id); if (workspace && workspace.ideUrl) { + try { + if (workspace.isDevWorkspace) { + await injectKubeConfig(workspace.namespace, workspace.id); + } + } catch (e) { + this.showAlert(`Injecting kubeconfig failed. ${e}`); + } await this.updateIdeUrl(workspace.ideUrl); } } diff --git a/packages/dashboard-frontend/src/services/dashboard-backend-client/devWorkspaceApi.ts b/packages/dashboard-frontend/src/services/dashboard-backend-client/devWorkspaceApi.ts index b0c2ec33f0..6bc57ea715 100644 --- a/packages/dashboard-frontend/src/services/dashboard-backend-client/devWorkspaceApi.ts +++ b/packages/dashboard-frontend/src/services/dashboard-backend-client/devWorkspaceApi.ts @@ -98,3 +98,13 @@ export async function putDockerConfig( throw `Failed to put dockerconfig. ${helpers.errors.getMessage(e)}`; } } + +export async function injectKubeConfig(namespace: string, devworkspaceId: string): Promise { + try { + await axios.post( + `${prefix}/namespace/${namespace}/devworkspaceId/${devworkspaceId}/kubeconfig`, + ); + } catch (e) { + throw `Failed to inject kubeconfig. ${helpers.errors.getMessage(e)}`; + } +}