Skip to content

Commit 2585113

Browse files
committed
Add support for the extends keyword
Allow one devcontainer.json to inherit another using the existing image metadata merge logic, rebasing the approach from spec#22 and CLI#311 onto current main.
1 parent 5dc7533 commit 2585113

11 files changed

Lines changed: 308 additions & 4 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
Notable changes.
44

5+
## Unreleased
6+
7+
- Add support for the `extends` keyword so one `devcontainer.json` can inherit another using the image metadata merge logic. (https://github.com/devcontainers/spec/issues/22, https://github.com/devcontainers/cli/pull/311)
8+
59
## August 2026
610

711
### [0.89.0]

src/spec-configuration/configuration.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export interface DevContainerFromImageConfig {
7373
features?: Record<string, string | boolean | Record<string, string | boolean>>;
7474
overrideFeatureInstallOrder?: string[];
7575
hostRequirements?: HostRequirements;
76+
extends?: string;
7677
customizations?: Record<string, any>;
7778
}
7879

@@ -110,6 +111,7 @@ export type DevContainerFromDockerfileConfig = {
110111
features?: Record<string, string | boolean | Record<string, string | boolean>>;
111112
overrideFeatureInstallOrder?: string[];
112113
hostRequirements?: HostRequirements;
114+
extends?: string;
113115
customizations?: Record<string, any>;
114116
} & (
115117
{
@@ -168,6 +170,7 @@ export interface DevContainerFromDockerComposeConfig {
168170
features?: Record<string, string | boolean | Record<string, string | boolean>>;
169171
overrideFeatureInstallOrder?: string[];
170172
hostRequirements?: HostRequirements;
173+
extends?: string;
171174
customizations?: Record<string, any>;
172175
}
173176

src/spec-node/configContainer.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,11 @@ import { URI } from 'vscode-uri';
1717
import { CLIHost } from '../spec-common/commonUtils';
1818
import { Log } from '../spec-utils/log';
1919
import { getDefaultDevContainerConfigPath, getDevContainerConfigPathIn } from '../spec-configuration/configurationCommonUtils';
20-
import { DevContainerConfig, DevContainerFromDockerComposeConfig, DevContainerFromDockerfileConfig, DevContainerFromImageConfig, updateFromOldProperties } from '../spec-configuration/configuration';
20+
import { DevContainerConfig, DevContainerFromDockerComposeConfig, DevContainerFromDockerfileConfig, DevContainerFromImageConfig, resolveConfigFilePath, updateFromOldProperties } from '../spec-configuration/configuration';
2121
import { ensureNoDisallowedFeatures } from './disallowedFeatures';
2222
import { DockerCLIParameters } from '../spec-shutdown/dockerUtils';
2323
import { createDocuments } from '../spec-configuration/editableFiles';
24+
import { mergeDevContainerConfigs } from './imageMetadata';
2425

2526

2627
export async function resolve(params: DockerResolverParameters, configFile: URI | undefined, overrideConfigFile: URI | undefined, providedIdLabels: string[] | undefined, additionalFeatures: Record<string, string | boolean | Record<string, string | boolean>>): Promise<ResolverResult> {
@@ -79,16 +80,48 @@ async function resolveWithLocalFolder(params: DockerResolverParameters, parsedAu
7980
return result;
8081
}
8182

82-
export async function readDevContainerConfigFile(cliHost: CLIHost, workspace: Workspace | undefined, configFile: URI, mountWorkspaceGitRoot: boolean, mountGitWorktreeCommonDir: boolean, output: Log, consistency?: BindMountConsistency, overrideConfigFile?: URI) {
83+
async function readDevContainerConfigObject(cliHost: CLIHost, configUri: URI, seen: Set<string>): Promise<DevContainerConfig | undefined> {
84+
const configKey = configUri.toString();
85+
if (seen.has(configKey)) {
86+
throw new ContainerError({ description: `Dev container config (${uriToFsPath(configUri, cliHost.platform)}) has a cyclic "extends" reference.` });
87+
}
88+
seen.add(configKey);
89+
8390
const documents = createDocuments(cliHost);
84-
const content = await documents.readDocument(overrideConfigFile ?? configFile);
91+
const content = await documents.readDocument(configUri);
8592
if (!content) {
8693
return undefined;
8794
}
8895
const raw = jsonc.parse(content) as DevContainerConfig | undefined;
8996
const updated = raw && updateFromOldProperties(raw);
9097
if (!updated || typeof updated !== 'object' || Array.isArray(updated)) {
91-
throw new ContainerError({ description: `Dev container config (${uriToFsPath(configFile, cliHost.platform)}) must contain a JSON object literal.` });
98+
throw new ContainerError({ description: `Dev container config (${uriToFsPath(configUri, cliHost.platform)}) must contain a JSON object literal.` });
99+
}
100+
101+
const extendsPath = updated.extends;
102+
delete updated.extends;
103+
if (!extendsPath) {
104+
return updated;
105+
}
106+
if (typeof extendsPath !== 'string' || !extendsPath.trim()) {
107+
throw new ContainerError({ description: `"extends" in (${uriToFsPath(configUri, cliHost.platform)}) must be a relative path to a JSON or JSONC file.` });
108+
}
109+
if (cliHost.path.isAbsolute(extendsPath) || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(extendsPath)) {
110+
throw new ContainerError({ description: `"extends" in (${uriToFsPath(configUri, cliHost.platform)}) must be a relative path within the same repository.` });
111+
}
112+
113+
const parentUri = resolveConfigFilePath(cliHost, configUri, extendsPath);
114+
const parent = await readDevContainerConfigObject(cliHost, parentUri, new Set(seen));
115+
if (!parent) {
116+
throw new ContainerError({ description: `Dev container config extended from (${uriToFsPath(configUri, cliHost.platform)}) was not found: ${uriToFsPath(parentUri, cliHost.platform)}.` });
117+
}
118+
return mergeDevContainerConfigs(parent, updated);
119+
}
120+
121+
export async function readDevContainerConfigFile(cliHost: CLIHost, workspace: Workspace | undefined, configFile: URI, mountWorkspaceGitRoot: boolean, mountGitWorktreeCommonDir: boolean, output: Log, consistency?: BindMountConsistency, overrideConfigFile?: URI) {
122+
const updated = await readDevContainerConfigObject(cliHost, overrideConfigFile ?? configFile, new Set());
123+
if (!updated) {
124+
return undefined;
92125
}
93126
const workspaceConfig = await getWorkspaceConfiguration(cliHost, workspace, updated, mountWorkspaceGitRoot, mountGitWorktreeCommonDir, output, consistency);
94127
const substitute0: SubstituteConfig = value => substitute({

src/spec-node/imageMetadata.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,81 @@ export function mergeConfiguration(config: DevContainerConfig, imageMetadata: Im
199199
return merged;
200200
}
201201

202+
/**
203+
* Merge a base `devcontainer.json` with an overlay using the image metadata merge logic
204+
* (https://containers.dev/implementors/spec/#merge-logic) so `extends` behaves the same as
205+
* combining a prebuilt image's metadata with a project's config.
206+
*/
207+
export function mergeDevContainerConfigs(base: DevContainerConfig, overlay: DevContainerConfig): DevContainerConfig {
208+
const metadata: ImageMetadataEntry[] = [base, overlay];
209+
const merged = {
210+
...base,
211+
...overlay,
212+
} as DevContainerConfig;
213+
delete merged.extends;
214+
215+
if (base.init || overlay.init) {
216+
merged.init = true;
217+
} else if (base.init === false || overlay.init === false) {
218+
merged.init = false;
219+
}
220+
221+
if (base.privileged || overlay.privileged) {
222+
merged.privileged = true;
223+
} else if (base.privileged === false || overlay.privileged === false) {
224+
merged.privileged = false;
225+
}
226+
227+
assignOrDelete(merged, 'capAdd', unionOrUndefined([base.capAdd, overlay.capAdd]));
228+
assignOrDelete(merged, 'securityOpt', unionOrUndefined([base.securityOpt, overlay.securityOpt]));
229+
assignOrDelete(merged, 'mounts', mergeMounts(metadata));
230+
assignOrDelete(merged, 'forwardPorts', mergeForwardPorts(metadata));
231+
assignOrDelete(merged, 'hostRequirements', mergeHostRequirements(metadata));
232+
233+
const remoteEnv = Object.assign({}, base.remoteEnv, overlay.remoteEnv);
234+
assignOrDelete(merged, 'remoteEnv', Object.keys(remoteEnv).length ? remoteEnv : undefined);
235+
const containerEnv = Object.assign({}, base.containerEnv, overlay.containerEnv);
236+
assignOrDelete(merged, 'containerEnv', Object.keys(containerEnv).length ? containerEnv : undefined);
237+
const portsAttributes = Object.assign({}, base.portsAttributes, overlay.portsAttributes);
238+
assignOrDelete(merged, 'portsAttributes', Object.keys(portsAttributes).length ? portsAttributes : undefined);
239+
const features = Object.assign({}, base.features, overlay.features);
240+
assignOrDelete(merged, 'features', Object.keys(features).length ? features : undefined);
241+
const customizations = Object.assign({}, base.customizations, overlay.customizations);
242+
assignOrDelete(merged, 'customizations', Object.keys(customizations).length ? customizations : undefined);
243+
244+
const runArgs = unionOrUndefined([
245+
'runArgs' in base ? base.runArgs : undefined,
246+
'runArgs' in overlay ? overlay.runArgs : undefined,
247+
]);
248+
if ('runArgs' in merged || runArgs) {
249+
(merged as DevContainerFromImageConfig).runArgs = runArgs;
250+
if (!runArgs) {
251+
delete (merged as DevContainerFromImageConfig).runArgs;
252+
}
253+
}
254+
255+
const runServices = unionOrUndefined([
256+
'dockerComposeFile' in base ? base.runServices : undefined,
257+
'dockerComposeFile' in overlay ? overlay.runServices : undefined,
258+
]);
259+
if ('runServices' in merged || runServices) {
260+
(merged as DevContainerFromDockerComposeConfig).runServices = runServices;
261+
if (!runServices) {
262+
delete (merged as DevContainerFromDockerComposeConfig).runServices;
263+
}
264+
}
265+
266+
return merged;
267+
}
268+
269+
function assignOrDelete<K extends keyof DevContainerConfig>(target: DevContainerConfig, key: K, value: DevContainerConfig[K] | undefined) {
270+
if (value !== undefined) {
271+
target[key] = value;
272+
} else {
273+
delete target[key];
274+
}
275+
}
276+
202277
function mergeForwardPorts(imageMetadata: ImageMetadataEntry[]): (number | string)[] | undefined {
203278
const forwardPorts = [
204279
...new Set(

src/test/configContainer.test.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
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 * as path from 'path';
7+
import { assert } from 'chai';
8+
import { URI } from 'vscode-uri';
9+
import { getCLIHost, loadNativeModule } from '../spec-common/commonUtils';
10+
import { DevContainerConfig, DevContainerFromImageConfig } from '../spec-configuration/configuration';
11+
import { readDevContainerConfigFile } from '../spec-node/configContainer';
12+
import { mergeDevContainerConfigs } from '../spec-node/imageMetadata';
13+
import { Workspace } from '../spec-utils/workspaces';
14+
import { nullLog } from '../spec-utils/log';
15+
16+
const workspace: Workspace = {
17+
isWorkspaceFile: false,
18+
workspaceOrFolderPath: '/foo/bar',
19+
rootFolderPath: '/foo/bar',
20+
configFolderPath: '/foo/bar',
21+
};
22+
23+
async function readConfig(relativePath: string) {
24+
const cliHost = await getCLIHost(process.cwd(), loadNativeModule, false);
25+
const configFile = URI.file(path.resolve(relativePath));
26+
return readDevContainerConfigFile(cliHost, workspace, configFile, false, false, nullLog);
27+
}
28+
29+
describe('readDevContainerConfigFile', function () {
30+
it('can read a basic configuration file', async function () {
31+
const configs = await readConfig('./src/test/configs/example/.devcontainer.json');
32+
assert.isOk(configs);
33+
assert.property(configs, 'config');
34+
assert.isOk(configs?.config.config);
35+
36+
const features = configs?.config.config.features as Record<string, string | boolean | Record<string, string | boolean>>;
37+
assert.hasAllKeys(features, ['ghcr.io/devcontainers/features/github-cli:1']);
38+
});
39+
40+
it('can resolve an "extends" file reference', async function () {
41+
const configs = await readConfig('./src/test/configs/extends/.devcontainer.json');
42+
assert.isOk(configs);
43+
const expectedConfig = {
44+
name: 'Overrides',
45+
image: 'mcr.microsoft.com/devcontainers/base:latest',
46+
forwardPorts: [80, 443],
47+
capAdd: ['SYS_PTRACE', 'NET_ADMIN'],
48+
hostRequirements: {
49+
cpus: 2,
50+
memory: `${8 * 2 ** 30}`,
51+
storage: undefined,
52+
gpu: undefined,
53+
},
54+
remoteEnv: {
55+
FROM_BASE: 'base',
56+
OVERRIDE_ME: 'child',
57+
},
58+
features: {
59+
'ghcr.io/devcontainers/features/docker-in-docker:1': {
60+
version: 'latest',
61+
moby: true,
62+
},
63+
'ghcr.io/devcontainers/features/go:1': {
64+
version: 'latest',
65+
},
66+
},
67+
};
68+
69+
assert.deepEqual(configs?.config.raw as any, expectedConfig);
70+
assert.notProperty(configs?.config.raw as any, 'extends');
71+
});
72+
73+
it('can resolve nested "extends" file references', async function () {
74+
const configs = await readConfig('./src/test/configs/extends/.devcontainer.nested.json');
75+
assert.isOk(configs);
76+
assert.strictEqual(configs?.config.raw.name, 'Nested');
77+
assert.deepEqual(configs?.config.raw.forwardPorts, [80, 443, 2222]);
78+
assert.strictEqual((configs?.config.raw as DevContainerFromImageConfig).image, 'mcr.microsoft.com/devcontainers/base:latest');
79+
});
80+
81+
it('rejects a cyclic "extends" reference', async function () {
82+
try {
83+
await readConfig('./src/test/configs/extends/.devcontainer.cycle-a.json');
84+
assert.fail('expected cyclic extends to throw');
85+
} catch (err: any) {
86+
assert.match(String(err.description || err.message), /cyclic "extends" reference/);
87+
}
88+
});
89+
90+
it('rejects a missing "extends" file', async function () {
91+
try {
92+
await readConfig('./src/test/configs/extends/.devcontainer.missing.json');
93+
assert.fail('expected missing extends to throw');
94+
} catch (err: any) {
95+
assert.match(String(err.description || err.message), /was not found/);
96+
}
97+
});
98+
});
99+
100+
describe('mergeDevContainerConfigs', function () {
101+
it('uses image metadata merge logic for overlapping properties', function () {
102+
const base: DevContainerConfig = {
103+
image: 'mcr.microsoft.com/devcontainers/base:latest',
104+
init: false,
105+
privileged: true,
106+
forwardPorts: [80],
107+
hostRequirements: {
108+
cpus: 4,
109+
memory: '4gb',
110+
},
111+
remoteUser: 'vscode',
112+
onCreateCommand: 'echo base',
113+
};
114+
const overlay: DevContainerConfig = {
115+
image: 'mcr.microsoft.com/devcontainers/javascript-node:latest',
116+
init: true,
117+
forwardPorts: [443],
118+
hostRequirements: {
119+
cpus: 2,
120+
memory: '8gb',
121+
},
122+
onCreateCommand: 'echo overlay',
123+
};
124+
125+
const merged = mergeDevContainerConfigs(base, overlay);
126+
assert.strictEqual((merged as DevContainerFromImageConfig).image, 'mcr.microsoft.com/devcontainers/javascript-node:latest');
127+
assert.strictEqual(merged.init, true);
128+
assert.strictEqual(merged.privileged, true);
129+
assert.deepEqual(merged.forwardPorts, [80, 443]);
130+
assert.strictEqual(merged.hostRequirements?.cpus, 4);
131+
assert.strictEqual(merged.hostRequirements?.memory, `${8 * 2 ** 30}`);
132+
assert.strictEqual(merged.remoteUser, 'vscode');
133+
assert.strictEqual(merged.onCreateCommand, 'echo overlay');
134+
});
135+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"name": "example configuration",
3+
"image": "mcr.microsoft.com/devcontainers/base:latest",
4+
"forwardPorts": [80],
5+
"capAdd": ["SYS_PTRACE"],
6+
"hostRequirements": {
7+
"cpus": 2,
8+
"memory": "8gb"
9+
},
10+
"remoteEnv": {
11+
"FROM_BASE": "base",
12+
"OVERRIDE_ME": "base"
13+
},
14+
"features": {
15+
"ghcr.io/devcontainers/features/go:1": {
16+
"version": "latest"
17+
}
18+
}
19+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"extends": "./.devcontainer.cycle-b.json",
3+
"image": "mcr.microsoft.com/devcontainers/base:latest"
4+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"extends": "./.devcontainer.cycle-a.json",
3+
"name": "cycle"
4+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"extends": "./.devcontainer.base.json",
3+
"name": "Overrides",
4+
"forwardPorts": [443],
5+
"capAdd": ["NET_ADMIN"],
6+
"hostRequirements": {
7+
"memory": "4gb"
8+
},
9+
"remoteEnv": {
10+
"OVERRIDE_ME": "child"
11+
},
12+
"features": {
13+
"ghcr.io/devcontainers/features/docker-in-docker:1": {
14+
"version": "latest",
15+
"moby": true
16+
}
17+
}
18+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"extends": "./does-not-exist.json",
3+
"image": "mcr.microsoft.com/devcontainers/base:latest"
4+
}

0 commit comments

Comments
 (0)