Skip to content

Commit

Permalink
[feat] Implement editor agnostic kubeconfig injection
Browse files Browse the repository at this point in the history
Signed-off-by: Josh Pinkney <[email protected]>
  • Loading branch information
JPinkney committed Jan 4, 2022
1 parent 1d2a9ad commit 13fc9cb
Show file tree
Hide file tree
Showing 9 changed files with 325 additions and 0 deletions.
37 changes: 37 additions & 0 deletions packages/dashboard-backend/src/api/kubeConfigAPI.ts
Original file line number Diff line number Diff line change
@@ -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);
}
},
);
}
8 changes: 8 additions & 0 deletions packages/dashboard-backend/src/devworkspace-client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@ import {
IDevWorkspaceClient,
IDevWorkspaceTemplateApi,
IDockerConfigApi,
IKubeConfigApi,
} from './types';
import { findApi } from './services/helpers';
import { DevWorkspaceTemplateApi } from './services/api/template-api';
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';

Expand All @@ -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);
}

Expand All @@ -60,6 +64,10 @@ export class DevWorkspaceClient implements IDevWorkspaceClient {
return this._serverConfigApi;
}

get kubeConfigApi(): IKubeConfigApi {
return this._kubeConfigApi;
}

async isDevWorkspaceApiEnabled(): Promise<boolean> {
if (this.apiEnabled !== undefined) {
return Promise.resolve(this.apiEnabled);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
/*
* 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<void> {
const currentPod = await this.getPodByDevWorkspaceId(namespace, devworkspaceId);
const podName = currentPod.metadata?.name || '';
const currentPodContainers = currentPod.spec?.containers || [];

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`,
]);
}
}

/**
* 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<k8s.V1Pod> {
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<string> {
// 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 '';
}

/**
* 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: '',
};
}
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ export interface IServerConfigApi {
getDefaultPlugins(): Promise<api.IWorkspacesDefaultPlugins[]>;
}

export interface IKubeConfigApi {
/**
* Inject the kubeconfig into all containers with the given devworkspaceId in a namespace
*/
injectKubeConfig(namespace: string, devworkspaceId: string): Promise<void>;
}

export type IDevWorkspaceCallbacks = {
onModified: (workspace: V1alpha2DevWorkspace) => void;
onDeleted: (workspaceId: string) => void;
Expand All @@ -104,6 +111,7 @@ export interface IDevWorkspaceClient {
templateApi: IDevWorkspaceTemplateApi;
dockerConfigApi: IDockerConfigApi;
serverConfigApi: IServerConfigApi;
kubeConfigApi: IKubeConfigApi;
isDevWorkspaceApiEnabled(): Promise<boolean>;
}

Expand Down
3 changes: 3 additions & 0 deletions packages/dashboard-backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -70,6 +71,8 @@ registerDockerConfigApi(server);

registerServerConfigApi(server);

registerKubeConfigApi(server);

if (CLUSTER_CONSOLE_URL) {
registerClusterInfo(server);
}
Expand Down
4 changes: 4 additions & 0 deletions packages/dashboard-backend/src/typings/models.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ declare namespace restParams {
templateName: string;
}

export interface INamespacedPodParam extends INamespacedParam {
devworkspaceId: string;
}

export interface IStatusUpdate {
error?: string;
message?: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
try {
await axios.post(
`${prefix}/namespace/${namespace}/devworkspaceId/${devworkspaceId}/kubeconfig`,
);
} catch (e) {
throw `Failed to inject kubeconfig. ${helpers.errors.getMessage(e)}`;
}
}
Loading

0 comments on commit 13fc9cb

Please sign in to comment.