Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 67 additions & 1 deletion src/spec-node/devContainersSpecCLI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { Log, LogDimensions, LogLevel, makeLog, mapLogLevel } from '../spec-util
import { probeRemoteEnv, runLifecycleHooks, runRemoteCommand, UserEnvProbe, setupInContainer } from '../spec-common/injectHeadless';
import { extendImage } from './containerFeatures';
import { dockerCLI, DockerCLIParameters, dockerPtyCLI, inspectContainer } from '../spec-shutdown/dockerUtils';
import { KubeCLIParameters } from '../spec-shutdown/kubeUtils';
import { createK8sContainerProperties } from './kubernetesContainer';
import { buildAndExtendDockerCompose, dockerComposeCLIConfig, getDefaultImageName, getProjectName, readDockerComposeConfig, readVersionPrefix } from './dockerCompose';
import { DevContainerFromDockerComposeConfig, DevContainerFromDockerfileConfig, getDockerComposeFilePaths } from '../spec-configuration/configuration';
import { workspaceFromPath } from '../spec-utils/workspaces';
Expand Down Expand Up @@ -1268,6 +1270,12 @@ function execOptions(y: Argv) {
'default-user-env-probe': { choices: ['none' as 'none', 'loginInteractiveShell' as 'loginInteractiveShell', 'interactiveShell' as 'interactiveShell', 'loginShell' as 'loginShell'], default: defaultDefaultUserEnvProbe, description: 'Default value for the devcontainer.json\'s "userEnvProbe".' },
'remote-env': { type: 'string', description: 'Remote environment variables of the format name=value. These will be added when executing the user commands.' },
'skip-feature-auto-mapping': { type: 'boolean', default: false, hidden: true, description: 'Temporary option for testing.' },
'kubectl-path': { type: 'string', default: 'kubectl', description: 'kubectl CLI path.' },
'k8s-context': { type: 'string', description: 'Kubernetes context to use (from kubeconfig).' },
'k8s-kubeconfig': { type: 'string', description: 'Path to kubeconfig file (for custom CA certificates or non-default configs).' },
'k8s-namespace': { type: 'string', description: 'Kubernetes namespace of the target pod.' },
'k8s-pod': { type: 'string', description: 'Kubernetes pod name to exec into.' },
'k8s-container': { type: 'string', description: 'Kubernetes container name within the pod.' },
})
.positional('cmd', {
type: 'string',
Expand All @@ -1288,7 +1296,15 @@ function execOptions(y: Argv) {
if (remoteEnvs?.some(remoteEnv => !/.+=.*/.test(remoteEnv))) {
throw new Error('Unmatched argument format: remote-env must match <name>=<value>');
}
if (!argv['container-id'] && !idLabels?.length && !argv['workspace-folder']) {
const isK8s = !!(argv['k8s-pod']);
if (isK8s) {
if (!argv['k8s-namespace']) {
throw new Error('--k8s-namespace is required when using --k8s-pod');
}
if (!argv['k8s-container']) {
throw new Error('--k8s-container is required when using --k8s-pod');
}
} else if (!argv['container-id'] && !idLabels?.length && !argv['workspace-folder']) {
argv['workspace-folder'] = process.cwd();
}
return true;
Expand Down Expand Up @@ -1330,6 +1346,12 @@ export async function doExec({
'default-user-env-probe': defaultUserEnvProbe,
'remote-env': addRemoteEnv,
'skip-feature-auto-mapping': skipFeatureAutoMapping,
'kubectl-path': kubectlPath,
'k8s-context': k8sContext,
'k8s-kubeconfig': k8sKubeconfig,
'k8s-namespace': k8sNamespace,
'k8s-pod': k8sPod,
'k8s-container': k8sContainer,
_: restArgs,
}: ExecArgs & { _?: string[] }) {
const disposables: (() => Promise<unknown> | undefined)[] = [];
Expand Down Expand Up @@ -1387,6 +1409,50 @@ export async function doExec({
const { common } = params;
const { cliHost } = common;
output = common.output;

// Kubernetes exec path — bypass Docker container discovery entirely.
if (k8sPod && k8sNamespace && k8sContainer) {
const kubeParams: KubeCLIParameters = {
cliHost,
kubectlCLI: kubectlPath || 'kubectl',
context: k8sContext,
kubeconfig: k8sKubeconfig,
namespace: k8sNamespace,
pod: k8sPod,
container: k8sContainer,
env: cliHost.env,
output,
};

// Optionally load devcontainer.json for remoteUser/remoteEnv/workspaceFolder.
const workspace = workspaceFolder ? workspaceFromPath(cliHost.path, workspaceFolder) : undefined;
const configPath = configFile ? configFile : workspace
? (await getDevContainerConfigPathIn(cliHost, workspace.configFolderPath)
|| (overrideConfigFile ? getDefaultDevContainerConfigPath(cliHost, workspace.configFolderPath) : undefined))
: overrideConfigFile;
const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, params.mountWorkspaceGitRoot, params.mountGitWorktreeCommonDir, output, undefined, overrideConfigFile) || undefined;

const remoteUser = configs?.config.config.remoteUser;
const remoteWorkspaceFolder = configs?.workspaceConfig.workspaceFolder || configs?.config.config.workspaceFolder;

const containerProperties = await createK8sContainerProperties(common, kubeParams, remoteWorkspaceFolder, remoteUser);

// Probe remote environment (shell init scripts, userEnvProbe setting)
// and merge with devcontainer.json remoteEnv + CLI --remote-env.
const k8sConfig = {
...(configs?.config.config || {}),
remoteEnv: { ...(configs?.config.config.remoteEnv || {}), ...envListToObj(addRemoteEnvs) },
};
const remoteEnv = probeRemoteEnv(common, containerProperties, k8sConfig);
const remoteCwd = containerProperties.remoteWorkspaceFolder || containerProperties.homeFolder;
await runRemoteCommand({ ...common, output, stdin: process.stdin, ...(logFormat !== 'json' ? { stdout: process.stdout, stderr: process.stderr } : {}) }, containerProperties, restArgs || [], remoteCwd, { remoteEnv: await remoteEnv, pty: isTTY, print: 'continuous' });
return {
code: 0,
dispose,
};
}

// Docker exec path.
const workspace = workspaceFolder ? workspaceFromPath(cliHost.path, workspaceFolder) : undefined;
const configPath = configFile ? configFile : workspace
? (await getDevContainerConfigPathIn(cliHost, workspace.configFolderPath)
Expand Down
6 changes: 6 additions & 0 deletions src/spec-node/featuresCLI/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ export const staticExecParams = {
'log-level': 'info' as 'info',
'log-format': 'text' as 'text',
'default-user-env-probe': 'loginInteractiveShell' as 'loginInteractiveShell',
'kubectl-path': 'kubectl',
'k8s-context': undefined,
'k8s-kubeconfig': undefined,
'k8s-namespace': undefined,
'k8s-pod': undefined,
'k8s-container': undefined,
};

export interface LaunchResult {
Expand Down
58 changes: 58 additions & 0 deletions src/spec-node/kubernetesContainer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { ResolverParameters, getContainerProperties, ContainerProperties } from '../spec-common/injectHeadless';
import { KubeCLIParameters, inspectPod, kubectlExecFunction, kubectlPtyExecFunction } from '../spec-shutdown/kubeUtils';

export function parseContainerUser(containerUser: string): { user: string | undefined; group: string | undefined } {
const [, user, , group] = /([^:]*)(:(.*))?/.exec(containerUser) as (string | undefined)[];
return { user: (user === '0' ? 'root' : user) || undefined, group };
}

export async function createK8sContainerProperties(
params: ResolverParameters,
kubeParams: KubeCLIParameters,
remoteWorkspaceFolder: string | undefined,
remoteUser: string | undefined,
): Promise<ContainerProperties> {
const inspecting = 'Inspecting pod';
const start = params.output.start(inspecting);
const podInfo = await inspectPod(kubeParams);
params.output.stop(inspecting, start);

const containerUser = remoteUser || podInfo.containerUser || 'root';
const { user, group } = parseContainerUser(containerUser);

// Use parsed user (not raw containerUser) because su only accepts
// usernames, not the user:group format that Docker's -u flag supports.
const remoteExec = kubectlExecFunction(kubeParams, user);
const remotePtyExec = await kubectlPtyExecFunction(kubeParams, user, params.loadNativeModule, params.allowInheritTTY);

// Only provide remoteExecAsRoot if the container already runs as root.
// In K8s, switching to root via su/runuser fails when runAsNonRoot is set
// or the container lacks privilege escalation tools.
const remoteExecAsRoot = user === 'root'
? remoteExec
: undefined;

return getContainerProperties({
params,
createdAt: podInfo.createdAt,
startedAt: podInfo.startedAt,
remoteWorkspaceFolder,
containerUser: user,
containerGroup: group,
// We pass an empty env here rather than undefined. The shell server
// launched by getContainerProperties will probe the actual runtime
// environment (resolving valueFrom refs) when probeRemoteEnv runs.
// Passing undefined would also probe env but can cause hangs when
// the shell server's PATH probe interacts with kubectl exec wrapping.
containerEnv: {},
remoteExec,
remotePtyExec,
remoteExecAsRoot,
rootShellServer: undefined,
});
}
202 changes: 202 additions & 0 deletions src/spec-shutdown/kubeUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { CLIHost, runCommandNoPty, ExecFunction, ExecParameters, Exec, PtyExecFunction, PtyExec, PtyExecParameters, plainExecAsPtyExec } from '../spec-common/commonUtils';
import * as ptyType from 'node-pty';
import { Log, LogEvent, makeLog } from '../spec-utils/log';
import { escapeRegExCharacters } from '../spec-utils/strings';

export interface KubeCLIParameters {
cliHost: CLIHost;
kubectlCLI: string;
context: string | undefined;
kubeconfig: string | undefined;
namespace: string;
pod: string;
container: string;
env: NodeJS.ProcessEnv;
output: Log;
}

export interface PodDetails {
name: string;
namespace: string;
createdAt: string;
startedAt: string;
containerUser: string;
}

export async function inspectPod(params: KubeCLIParameters): Promise<PodDetails> {
const result = await kubectlCLI(params, 'get', 'pod', params.pod,
'-n', params.namespace,
'-o', 'json',
);
const pod = JSON.parse(result.stdout.toString());
const containerSpec = pod.spec?.containers?.find((c: { name: string }) => c.name === params.container)
|| pod.spec?.containers?.[0];
const containerStatus = pod.status?.containerStatuses?.find((c: { name: string }) => c.name === params.container)
|| pod.status?.containerStatuses?.[0];

const securityContext = containerSpec?.securityContext || pod.spec?.securityContext || {};
const runAsUser = securityContext.runAsUser;
const containerUser = runAsUser ? String(runAsUser) : 'root';

// Pod spec env only contains static values — valueFrom refs (ConfigMaps,
// Secrets, Downward API) are resolved by the kubelet at runtime and aren't
// visible here. We deliberately omit containerEnv so getContainerProperties
// probes the actual runtime environment via the shell server.

return {
name: pod.metadata.name,
namespace: pod.metadata.namespace,
createdAt: pod.metadata.creationTimestamp || '',
startedAt: containerStatus?.state?.running?.startedAt || pod.metadata.creationTimestamp || '',
containerUser,
};
}

/**
* kubectl exec doesn't support -u (user), -e (env), or -w (cwd) flags
* like `docker exec` does. When env/cwd/user switching is needed, we wrap
* the target command in a shell invocation. When none of these are needed,
* we pass the command through directly to avoid unnecessary shell layers
* (important for interactive shells used by the shell server).
*
* For non-root users, we use `su -s /bin/sh <user> -c` (no login shell,
* matching Docker's `-u` behaviour).
*/
function buildWrappedCommand(user: string | undefined, params: ExecParameters | PtyExecParameters): { cmd: string; args: string[] } {
const { env, cwd, cmd, args } = params;

const hasEnv = env && Object.keys(env).length > 0;
const hasCwd = !!cwd;
const needsUserSwitch = !!(user && user !== 'root');

// Fast path: no wrapping needed when there's nothing to set up.
if (!hasEnv && !hasCwd && !needsUserSwitch) {
return { cmd, args: args || [] };
}

const parts: string[] = [];

if (hasEnv) {
for (const key of Object.keys(env!)) {
if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
parts.push(`export ${key}=${shellQuote(env![key] ?? '')};`);
}
}
}

if (hasCwd) {
parts.push(`cd ${shellQuote(cwd!)};`);
}

parts.push(`exec ${shellQuote(cmd)}`);
if (args) {
parts.push(...args.map(shellQuote));
}

const script = parts.join(' ');

if (needsUserSwitch) {
if (!/^[a-zA-Z0-9_][\w.-]*$/.test(user!)) {
throw new Error(`Invalid container user: ${user}`);
}
return { cmd: 'su', args: ['-s', '/bin/sh', user!, '-c', script] };
}

return { cmd: '/bin/sh', args: ['-c', script] };
}

function shellQuote(s: string): string {
const sanitised = s.replace(/\0/g, '');
if (/^[a-zA-Z0-9_./:=-]+$/.test(sanitised)) {
return sanitised;
}
return `'${sanitised.replace(/'/g, `'\\''`)}'`;
}

function toKubectlExecArgs(params: KubeCLIParameters, user: string | undefined, execParams: ExecParameters | PtyExecParameters, pty: boolean): { argsPrefix: string[]; args: string[] } {
const kubectlArgs = [...globalKubeArgs(params), 'exec', '-i'];
if (pty) {
kubectlArgs.push('-t');
}
kubectlArgs.push(params.pod, '-n', params.namespace, '-c', params.container, '--');

const argsPrefix = kubectlArgs.slice();

const wrapped = buildWrappedCommand(user, execParams);
kubectlArgs.push(wrapped.cmd, ...wrapped.args);

return { argsPrefix, args: kubectlArgs };
}

export function kubectlExecFunction(params: KubeCLIParameters, user: string | undefined, allocatePtyIfPossible = false): ExecFunction {
return async function (execParams: ExecParameters): Promise<Exec> {
const canAllocatePty = allocatePtyIfPossible && process.stdin.isTTY && execParams.stdio?.[0] === 'inherit';
const { argsPrefix, args: execArgs } = toKubectlExecArgs(params, user, execParams, canAllocatePty);
return params.cliHost.exec({
cmd: params.kubectlCLI,
args: execArgs,
env: params.env,
stdio: execParams.stdio,
output: replacingKubectlExecLog(execParams.output, params.kubectlCLI, argsPrefix),
});
};
}

export async function kubectlPtyExecFunction(params: KubeCLIParameters, user: string | undefined, loadNativeModule: <T>(moduleName: string) => Promise<T | undefined>, allowInheritTTY: boolean): Promise<PtyExecFunction> {
const pty = await loadNativeModule<typeof ptyType>('node-pty');
if (!pty) {
const plain = kubectlExecFunction(params, user, true);
return plainExecAsPtyExec(plain, allowInheritTTY);
}

return async function (execParams: PtyExecParameters): Promise<PtyExec> {
const { argsPrefix, args: execArgs } = toKubectlExecArgs(params, user, execParams, true);
return params.cliHost.ptyExec({
cmd: params.kubectlCLI,
args: execArgs,
env: params.env,
output: replacingKubectlExecLog(execParams.output, params.kubectlCLI, argsPrefix),
});
};
}

function replacingKubectlExecLog(original: Log, cmd: string, args: string[]) {
const search = `Run: ${cmd} ${(args || []).join(' ').replace(/\n.*/g, '')}`;
const searchR = new RegExp(escapeRegExCharacters(search), 'g');
return makeLog({
...original,
get dimensions() {
return original.dimensions;
},
event: (e: LogEvent) => original.event('text' in e ? {
...e,
text: e.text.replace(searchR, 'Run in container:'),
} : e),
});
}

function globalKubeArgs(params: KubeCLIParameters): string[] {
const args: string[] = [];
if (params.kubeconfig) {
args.push('--kubeconfig', params.kubeconfig);
}
if (params.context) {
args.push('--context', params.context);
}
return args;
}

async function kubectlCLI(params: KubeCLIParameters, ...args: string[]) {
return runCommandNoPty({
exec: params.cliHost.exec,
cmd: params.kubectlCLI,
args: [...globalKubeArgs(params), ...args],
env: params.env,
output: params.output,
});
}
Loading