Skip to content

Commit ff0445e

Browse files
committed
Add Kubernetes exec support to devcontainer exec
Enable `devcontainer exec` to run commands inside Kubernetes pods via `kubectl exec`, as an alternative to the existing Docker exec path. New CLI flags for the `exec` command: --kubectl-path kubectl CLI path (default: kubectl) --k8s-context Kubernetes context (from kubeconfig) --k8s-kubeconfig Path to kubeconfig file (for custom CAs) --k8s-namespace Target pod namespace (required with --k8s-pod) --k8s-pod Target pod name --k8s-container Target container name (required with --k8s-pod) Since kubectl exec doesn't support Docker's -u (user), -e (env), or -w (cwd) flags natively, these are handled by wrapping commands in shell invocations with proper POSIX quoting. A fast path avoids shell wrapping when none of these are needed (e.g., for the shell server). User switching for non-root remoteUser uses `su -s /bin/sh <user> -c` (no login shell, matching Docker's -u behaviour). The user parameter is validated against a strict regex to prevent shell injection. Environment probing uses probeRemoteEnv to capture the actual runtime environment inside the container, correctly resolving K8s valueFrom env vars (ConfigMaps, Secrets, Downward API) that aren't visible in the static pod spec. Relates to: devcontainers/spec#672, microsoft/vscode-remote-release#6413
1 parent 65f98a5 commit ff0445e

5 files changed

Lines changed: 423 additions & 1 deletion

File tree

src/spec-node/devContainersSpecCLI.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import { Log, LogDimensions, LogLevel, makeLog, mapLogLevel } from '../spec-util
1717
import { probeRemoteEnv, runLifecycleHooks, runRemoteCommand, UserEnvProbe, setupInContainer } from '../spec-common/injectHeadless';
1818
import { extendImage } from './containerFeatures';
1919
import { dockerCLI, DockerCLIParameters, dockerPtyCLI, inspectContainer } from '../spec-shutdown/dockerUtils';
20+
import { KubeCLIParameters } from '../spec-shutdown/kubeUtils';
21+
import { createK8sContainerProperties } from './kubernetesContainer';
2022
import { buildAndExtendDockerCompose, dockerComposeCLIConfig, getDefaultImageName, getProjectName, readDockerComposeConfig, readVersionPrefix } from './dockerCompose';
2123
import { DevContainerFromDockerComposeConfig, DevContainerFromDockerfileConfig, getDockerComposeFilePaths } from '../spec-configuration/configuration';
2224
import { workspaceFromPath } from '../spec-utils/workspaces';
@@ -1268,6 +1270,12 @@ function execOptions(y: Argv) {
12681270
'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".' },
12691271
'remote-env': { type: 'string', description: 'Remote environment variables of the format name=value. These will be added when executing the user commands.' },
12701272
'skip-feature-auto-mapping': { type: 'boolean', default: false, hidden: true, description: 'Temporary option for testing.' },
1273+
'kubectl-path': { type: 'string', default: 'kubectl', description: 'kubectl CLI path.' },
1274+
'k8s-context': { type: 'string', description: 'Kubernetes context to use (from kubeconfig).' },
1275+
'k8s-kubeconfig': { type: 'string', description: 'Path to kubeconfig file (for custom CA certificates or non-default configs).' },
1276+
'k8s-namespace': { type: 'string', description: 'Kubernetes namespace of the target pod.' },
1277+
'k8s-pod': { type: 'string', description: 'Kubernetes pod name to exec into.' },
1278+
'k8s-container': { type: 'string', description: 'Kubernetes container name within the pod.' },
12711279
})
12721280
.positional('cmd', {
12731281
type: 'string',
@@ -1288,7 +1296,15 @@ function execOptions(y: Argv) {
12881296
if (remoteEnvs?.some(remoteEnv => !/.+=.*/.test(remoteEnv))) {
12891297
throw new Error('Unmatched argument format: remote-env must match <name>=<value>');
12901298
}
1291-
if (!argv['container-id'] && !idLabels?.length && !argv['workspace-folder']) {
1299+
const isK8s = !!(argv['k8s-pod']);
1300+
if (isK8s) {
1301+
if (!argv['k8s-namespace']) {
1302+
throw new Error('--k8s-namespace is required when using --k8s-pod');
1303+
}
1304+
if (!argv['k8s-container']) {
1305+
throw new Error('--k8s-container is required when using --k8s-pod');
1306+
}
1307+
} else if (!argv['container-id'] && !idLabels?.length && !argv['workspace-folder']) {
12921308
argv['workspace-folder'] = process.cwd();
12931309
}
12941310
return true;
@@ -1330,6 +1346,12 @@ export async function doExec({
13301346
'default-user-env-probe': defaultUserEnvProbe,
13311347
'remote-env': addRemoteEnv,
13321348
'skip-feature-auto-mapping': skipFeatureAutoMapping,
1349+
'kubectl-path': kubectlPath,
1350+
'k8s-context': k8sContext,
1351+
'k8s-kubeconfig': k8sKubeconfig,
1352+
'k8s-namespace': k8sNamespace,
1353+
'k8s-pod': k8sPod,
1354+
'k8s-container': k8sContainer,
13331355
_: restArgs,
13341356
}: ExecArgs & { _?: string[] }) {
13351357
const disposables: (() => Promise<unknown> | undefined)[] = [];
@@ -1387,6 +1409,50 @@ export async function doExec({
13871409
const { common } = params;
13881410
const { cliHost } = common;
13891411
output = common.output;
1412+
1413+
// Kubernetes exec path — bypass Docker container discovery entirely.
1414+
if (k8sPod && k8sNamespace && k8sContainer) {
1415+
const kubeParams: KubeCLIParameters = {
1416+
cliHost,
1417+
kubectlCLI: kubectlPath || 'kubectl',
1418+
context: k8sContext,
1419+
kubeconfig: k8sKubeconfig,
1420+
namespace: k8sNamespace,
1421+
pod: k8sPod,
1422+
container: k8sContainer,
1423+
env: cliHost.env,
1424+
output,
1425+
};
1426+
1427+
// Optionally load devcontainer.json for remoteUser/remoteEnv/workspaceFolder.
1428+
const workspace = workspaceFolder ? workspaceFromPath(cliHost.path, workspaceFolder) : undefined;
1429+
const configPath = configFile ? configFile : workspace
1430+
? (await getDevContainerConfigPathIn(cliHost, workspace.configFolderPath)
1431+
|| (overrideConfigFile ? getDefaultDevContainerConfigPath(cliHost, workspace.configFolderPath) : undefined))
1432+
: overrideConfigFile;
1433+
const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, params.mountWorkspaceGitRoot, params.mountGitWorktreeCommonDir, output, undefined, overrideConfigFile) || undefined;
1434+
1435+
const remoteUser = configs?.config.config.remoteUser;
1436+
const remoteWorkspaceFolder = configs?.workspaceConfig.workspaceFolder || configs?.config.config.workspaceFolder;
1437+
1438+
const containerProperties = await createK8sContainerProperties(common, kubeParams, remoteWorkspaceFolder, remoteUser);
1439+
1440+
// Probe remote environment (shell init scripts, userEnvProbe setting)
1441+
// and merge with devcontainer.json remoteEnv + CLI --remote-env.
1442+
const k8sConfig = {
1443+
...(configs?.config.config || {}),
1444+
remoteEnv: { ...(configs?.config.config.remoteEnv || {}), ...envListToObj(addRemoteEnvs) },
1445+
};
1446+
const remoteEnv = probeRemoteEnv(common, containerProperties, k8sConfig);
1447+
const remoteCwd = containerProperties.remoteWorkspaceFolder || containerProperties.homeFolder;
1448+
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' });
1449+
return {
1450+
code: 0,
1451+
dispose,
1452+
};
1453+
}
1454+
1455+
// Docker exec path.
13901456
const workspace = workspaceFolder ? workspaceFromPath(cliHost.path, workspaceFolder) : undefined;
13911457
const configPath = configFile ? configFile : workspace
13921458
? (await getDevContainerConfigPathIn(cliHost, workspace.configFolderPath)

src/spec-node/featuresCLI/utils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ export const staticExecParams = {
4444
'log-level': 'info' as 'info',
4545
'log-format': 'text' as 'text',
4646
'default-user-env-probe': 'loginInteractiveShell' as 'loginInteractiveShell',
47+
'kubectl-path': 'kubectl',
48+
'k8s-context': undefined,
49+
'k8s-kubeconfig': undefined,
50+
'k8s-namespace': undefined,
51+
'k8s-pod': undefined,
52+
'k8s-container': undefined,
4753
};
4854

4955
export interface LaunchResult {
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { ResolverParameters, getContainerProperties, ContainerProperties } from '../spec-common/injectHeadless';
7+
import { KubeCLIParameters, inspectPod, kubectlExecFunction, kubectlPtyExecFunction } from '../spec-shutdown/kubeUtils';
8+
9+
export function parseContainerUser(containerUser: string): { user: string | undefined; group: string | undefined } {
10+
const [, user, , group] = /([^:]*)(:(.*))?/.exec(containerUser) as (string | undefined)[];
11+
return { user: (user === '0' ? 'root' : user) || undefined, group };
12+
}
13+
14+
export async function createK8sContainerProperties(
15+
params: ResolverParameters,
16+
kubeParams: KubeCLIParameters,
17+
remoteWorkspaceFolder: string | undefined,
18+
remoteUser: string | undefined,
19+
): Promise<ContainerProperties> {
20+
const inspecting = 'Inspecting pod';
21+
const start = params.output.start(inspecting);
22+
const podInfo = await inspectPod(kubeParams);
23+
params.output.stop(inspecting, start);
24+
25+
const containerUser = remoteUser || podInfo.containerUser || 'root';
26+
const { user, group } = parseContainerUser(containerUser);
27+
28+
// Use parsed user (not raw containerUser) because su only accepts
29+
// usernames, not the user:group format that Docker's -u flag supports.
30+
const remoteExec = kubectlExecFunction(kubeParams, user);
31+
const remotePtyExec = await kubectlPtyExecFunction(kubeParams, user, params.loadNativeModule, params.allowInheritTTY);
32+
33+
// Only provide remoteExecAsRoot if the container already runs as root.
34+
// In K8s, switching to root via su/runuser fails when runAsNonRoot is set
35+
// or the container lacks privilege escalation tools.
36+
const remoteExecAsRoot = user === 'root'
37+
? remoteExec
38+
: undefined;
39+
40+
return getContainerProperties({
41+
params,
42+
createdAt: podInfo.createdAt,
43+
startedAt: podInfo.startedAt,
44+
remoteWorkspaceFolder,
45+
containerUser: user,
46+
containerGroup: group,
47+
// We pass an empty env here rather than undefined. The shell server
48+
// launched by getContainerProperties will probe the actual runtime
49+
// environment (resolving valueFrom refs) when probeRemoteEnv runs.
50+
// Passing undefined would also probe env but can cause hangs when
51+
// the shell server's PATH probe interacts with kubectl exec wrapping.
52+
containerEnv: {},
53+
remoteExec,
54+
remotePtyExec,
55+
remoteExecAsRoot,
56+
rootShellServer: undefined,
57+
});
58+
}

src/spec-shutdown/kubeUtils.ts

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { CLIHost, runCommandNoPty, ExecFunction, ExecParameters, Exec, PtyExecFunction, PtyExec, PtyExecParameters, plainExecAsPtyExec } from '../spec-common/commonUtils';
7+
import * as ptyType from 'node-pty';
8+
import { Log, LogEvent, makeLog } from '../spec-utils/log';
9+
import { escapeRegExCharacters } from '../spec-utils/strings';
10+
11+
export interface KubeCLIParameters {
12+
cliHost: CLIHost;
13+
kubectlCLI: string;
14+
context: string | undefined;
15+
kubeconfig: string | undefined;
16+
namespace: string;
17+
pod: string;
18+
container: string;
19+
env: NodeJS.ProcessEnv;
20+
output: Log;
21+
}
22+
23+
export interface PodDetails {
24+
name: string;
25+
namespace: string;
26+
createdAt: string;
27+
startedAt: string;
28+
containerUser: string;
29+
}
30+
31+
export async function inspectPod(params: KubeCLIParameters): Promise<PodDetails> {
32+
const result = await kubectlCLI(params, 'get', 'pod', params.pod,
33+
'-n', params.namespace,
34+
'-o', 'json',
35+
);
36+
const pod = JSON.parse(result.stdout.toString());
37+
const containerSpec = pod.spec?.containers?.find((c: { name: string }) => c.name === params.container)
38+
|| pod.spec?.containers?.[0];
39+
const containerStatus = pod.status?.containerStatuses?.find((c: { name: string }) => c.name === params.container)
40+
|| pod.status?.containerStatuses?.[0];
41+
42+
const securityContext = containerSpec?.securityContext || pod.spec?.securityContext || {};
43+
const runAsUser = securityContext.runAsUser;
44+
const containerUser = runAsUser ? String(runAsUser) : 'root';
45+
46+
// Pod spec env only contains static values — valueFrom refs (ConfigMaps,
47+
// Secrets, Downward API) are resolved by the kubelet at runtime and aren't
48+
// visible here. We deliberately omit containerEnv so getContainerProperties
49+
// probes the actual runtime environment via the shell server.
50+
51+
return {
52+
name: pod.metadata.name,
53+
namespace: pod.metadata.namespace,
54+
createdAt: pod.metadata.creationTimestamp || '',
55+
startedAt: containerStatus?.state?.running?.startedAt || pod.metadata.creationTimestamp || '',
56+
containerUser,
57+
};
58+
}
59+
60+
/**
61+
* kubectl exec doesn't support -u (user), -e (env), or -w (cwd) flags
62+
* like `docker exec` does. When env/cwd/user switching is needed, we wrap
63+
* the target command in a shell invocation. When none of these are needed,
64+
* we pass the command through directly to avoid unnecessary shell layers
65+
* (important for interactive shells used by the shell server).
66+
*
67+
* For non-root users, we use `su -s /bin/sh <user> -c` (no login shell,
68+
* matching Docker's `-u` behaviour).
69+
*/
70+
function buildWrappedCommand(user: string | undefined, params: ExecParameters | PtyExecParameters): { cmd: string; args: string[] } {
71+
const { env, cwd, cmd, args } = params;
72+
73+
const hasEnv = env && Object.keys(env).length > 0;
74+
const hasCwd = !!cwd;
75+
const needsUserSwitch = !!(user && user !== 'root');
76+
77+
// Fast path: no wrapping needed when there's nothing to set up.
78+
if (!hasEnv && !hasCwd && !needsUserSwitch) {
79+
return { cmd, args: args || [] };
80+
}
81+
82+
const parts: string[] = [];
83+
84+
if (hasEnv) {
85+
for (const key of Object.keys(env!)) {
86+
if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
87+
parts.push(`export ${key}=${shellQuote(env![key] ?? '')};`);
88+
}
89+
}
90+
}
91+
92+
if (hasCwd) {
93+
parts.push(`cd ${shellQuote(cwd!)};`);
94+
}
95+
96+
parts.push(`exec ${shellQuote(cmd)}`);
97+
if (args) {
98+
parts.push(...args.map(shellQuote));
99+
}
100+
101+
const script = parts.join(' ');
102+
103+
if (needsUserSwitch) {
104+
if (!/^[a-zA-Z0-9_][\w.-]*$/.test(user!)) {
105+
throw new Error(`Invalid container user: ${user}`);
106+
}
107+
return { cmd: 'su', args: ['-s', '/bin/sh', user!, '-c', script] };
108+
}
109+
110+
return { cmd: '/bin/sh', args: ['-c', script] };
111+
}
112+
113+
function shellQuote(s: string): string {
114+
const sanitised = s.replace(/\0/g, '');
115+
if (/^[a-zA-Z0-9_./:=-]+$/.test(sanitised)) {
116+
return sanitised;
117+
}
118+
return `'${sanitised.replace(/'/g, `'\\''`)}'`;
119+
}
120+
121+
function toKubectlExecArgs(params: KubeCLIParameters, user: string | undefined, execParams: ExecParameters | PtyExecParameters, pty: boolean): { argsPrefix: string[]; args: string[] } {
122+
const kubectlArgs = [...globalKubeArgs(params), 'exec', '-i'];
123+
if (pty) {
124+
kubectlArgs.push('-t');
125+
}
126+
kubectlArgs.push(params.pod, '-n', params.namespace, '-c', params.container, '--');
127+
128+
const argsPrefix = kubectlArgs.slice();
129+
130+
const wrapped = buildWrappedCommand(user, execParams);
131+
kubectlArgs.push(wrapped.cmd, ...wrapped.args);
132+
133+
return { argsPrefix, args: kubectlArgs };
134+
}
135+
136+
export function kubectlExecFunction(params: KubeCLIParameters, user: string | undefined, allocatePtyIfPossible = false): ExecFunction {
137+
return async function (execParams: ExecParameters): Promise<Exec> {
138+
const canAllocatePty = allocatePtyIfPossible && process.stdin.isTTY && execParams.stdio?.[0] === 'inherit';
139+
const { argsPrefix, args: execArgs } = toKubectlExecArgs(params, user, execParams, canAllocatePty);
140+
return params.cliHost.exec({
141+
cmd: params.kubectlCLI,
142+
args: execArgs,
143+
env: params.env,
144+
stdio: execParams.stdio,
145+
output: replacingKubectlExecLog(execParams.output, params.kubectlCLI, argsPrefix),
146+
});
147+
};
148+
}
149+
150+
export async function kubectlPtyExecFunction(params: KubeCLIParameters, user: string | undefined, loadNativeModule: <T>(moduleName: string) => Promise<T | undefined>, allowInheritTTY: boolean): Promise<PtyExecFunction> {
151+
const pty = await loadNativeModule<typeof ptyType>('node-pty');
152+
if (!pty) {
153+
const plain = kubectlExecFunction(params, user, true);
154+
return plainExecAsPtyExec(plain, allowInheritTTY);
155+
}
156+
157+
return async function (execParams: PtyExecParameters): Promise<PtyExec> {
158+
const { argsPrefix, args: execArgs } = toKubectlExecArgs(params, user, execParams, true);
159+
return params.cliHost.ptyExec({
160+
cmd: params.kubectlCLI,
161+
args: execArgs,
162+
env: params.env,
163+
output: replacingKubectlExecLog(execParams.output, params.kubectlCLI, argsPrefix),
164+
});
165+
};
166+
}
167+
168+
function replacingKubectlExecLog(original: Log, cmd: string, args: string[]) {
169+
const search = `Run: ${cmd} ${(args || []).join(' ').replace(/\n.*/g, '')}`;
170+
const searchR = new RegExp(escapeRegExCharacters(search), 'g');
171+
return makeLog({
172+
...original,
173+
get dimensions() {
174+
return original.dimensions;
175+
},
176+
event: (e: LogEvent) => original.event('text' in e ? {
177+
...e,
178+
text: e.text.replace(searchR, 'Run in container:'),
179+
} : e),
180+
});
181+
}
182+
183+
function globalKubeArgs(params: KubeCLIParameters): string[] {
184+
const args: string[] = [];
185+
if (params.kubeconfig) {
186+
args.push('--kubeconfig', params.kubeconfig);
187+
}
188+
if (params.context) {
189+
args.push('--context', params.context);
190+
}
191+
return args;
192+
}
193+
194+
async function kubectlCLI(params: KubeCLIParameters, ...args: string[]) {
195+
return runCommandNoPty({
196+
exec: params.cliHost.exec,
197+
cmd: params.kubectlCLI,
198+
args: [...globalKubeArgs(params), ...args],
199+
env: params.env,
200+
output: params.output,
201+
});
202+
}

0 commit comments

Comments
 (0)