Skip to content
Draft
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

Notable changes.

## Unreleased

- 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)

## August 2026

### [0.89.0]
Expand Down
3 changes: 3 additions & 0 deletions src/spec-configuration/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export interface DevContainerFromImageConfig {
features?: Record<string, string | boolean | Record<string, string | boolean>>;
overrideFeatureInstallOrder?: string[];
hostRequirements?: HostRequirements;
extends?: string;
customizations?: Record<string, any>;
}

Expand Down Expand Up @@ -110,6 +111,7 @@ export type DevContainerFromDockerfileConfig = {
features?: Record<string, string | boolean | Record<string, string | boolean>>;
overrideFeatureInstallOrder?: string[];
hostRequirements?: HostRequirements;
extends?: string;
customizations?: Record<string, any>;
} & (
{
Expand Down Expand Up @@ -168,6 +170,7 @@ export interface DevContainerFromDockerComposeConfig {
features?: Record<string, string | boolean | Record<string, string | boolean>>;
overrideFeatureInstallOrder?: string[];
hostRequirements?: HostRequirements;
extends?: string;
customizations?: Record<string, any>;
}

Expand Down
41 changes: 37 additions & 4 deletions src/spec-node/configContainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@ import { URI } from 'vscode-uri';
import { CLIHost } from '../spec-common/commonUtils';
import { Log } from '../spec-utils/log';
import { getDefaultDevContainerConfigPath, getDevContainerConfigPathIn } from '../spec-configuration/configurationCommonUtils';
import { DevContainerConfig, DevContainerFromDockerComposeConfig, DevContainerFromDockerfileConfig, DevContainerFromImageConfig, updateFromOldProperties } from '../spec-configuration/configuration';
import { DevContainerConfig, DevContainerFromDockerComposeConfig, DevContainerFromDockerfileConfig, DevContainerFromImageConfig, resolveConfigFilePath, updateFromOldProperties } from '../spec-configuration/configuration';
import { ensureNoDisallowedFeatures } from './disallowedFeatures';
import { DockerCLIParameters } from '../spec-shutdown/dockerUtils';
import { createDocuments } from '../spec-configuration/editableFiles';
import { mergeDevContainerConfigs } from './imageMetadata';


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> {
Expand Down Expand Up @@ -79,16 +80,48 @@ async function resolveWithLocalFolder(params: DockerResolverParameters, parsedAu
return result;
}

export async function readDevContainerConfigFile(cliHost: CLIHost, workspace: Workspace | undefined, configFile: URI, mountWorkspaceGitRoot: boolean, mountGitWorktreeCommonDir: boolean, output: Log, consistency?: BindMountConsistency, overrideConfigFile?: URI) {
async function readDevContainerConfigObject(cliHost: CLIHost, configUri: URI, seen: Set<string>): Promise<DevContainerConfig | undefined> {
const configKey = configUri.toString();
if (seen.has(configKey)) {
throw new ContainerError({ description: `Dev container config (${uriToFsPath(configUri, cliHost.platform)}) has a cyclic "extends" reference.` });
}
seen.add(configKey);

const documents = createDocuments(cliHost);
const content = await documents.readDocument(overrideConfigFile ?? configFile);
const content = await documents.readDocument(configUri);
if (!content) {
return undefined;
}
const raw = jsonc.parse(content) as DevContainerConfig | undefined;
const updated = raw && updateFromOldProperties(raw);
if (!updated || typeof updated !== 'object' || Array.isArray(updated)) {
throw new ContainerError({ description: `Dev container config (${uriToFsPath(configFile, cliHost.platform)}) must contain a JSON object literal.` });
throw new ContainerError({ description: `Dev container config (${uriToFsPath(configUri, cliHost.platform)}) must contain a JSON object literal.` });
}

const extendsPath = updated.extends;
delete updated.extends;
if (!extendsPath) {
return updated;
}
if (typeof extendsPath !== 'string' || !extendsPath.trim()) {
throw new ContainerError({ description: `"extends" in (${uriToFsPath(configUri, cliHost.platform)}) must be a relative path to a JSON or JSONC file.` });
}
if (cliHost.path.isAbsolute(extendsPath) || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(extendsPath)) {
throw new ContainerError({ description: `"extends" in (${uriToFsPath(configUri, cliHost.platform)}) must be a relative path within the same repository.` });
}

const parentUri = resolveConfigFilePath(cliHost, configUri, extendsPath);
const parent = await readDevContainerConfigObject(cliHost, parentUri, new Set(seen));
if (!parent) {
throw new ContainerError({ description: `Dev container config extended from (${uriToFsPath(configUri, cliHost.platform)}) was not found: ${uriToFsPath(parentUri, cliHost.platform)}.` });
}
return mergeDevContainerConfigs(parent, updated);
}

export async function readDevContainerConfigFile(cliHost: CLIHost, workspace: Workspace | undefined, configFile: URI, mountWorkspaceGitRoot: boolean, mountGitWorktreeCommonDir: boolean, output: Log, consistency?: BindMountConsistency, overrideConfigFile?: URI) {
const updated = await readDevContainerConfigObject(cliHost, overrideConfigFile ?? configFile, new Set());
if (!updated) {
return undefined;
}
const workspaceConfig = await getWorkspaceConfiguration(cliHost, workspace, updated, mountWorkspaceGitRoot, mountGitWorktreeCommonDir, output, consistency);
const substitute0: SubstituteConfig = value => substitute({
Expand Down
75 changes: 75 additions & 0 deletions src/spec-node/imageMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,81 @@ export function mergeConfiguration(config: DevContainerConfig, imageMetadata: Im
return merged;
}

/**
* Merge a base `devcontainer.json` with an overlay using the image metadata merge logic
* (https://containers.dev/implementors/spec/#merge-logic) so `extends` behaves the same as
* combining a prebuilt image's metadata with a project's config.
*/
export function mergeDevContainerConfigs(base: DevContainerConfig, overlay: DevContainerConfig): DevContainerConfig {
const metadata: ImageMetadataEntry[] = [base, overlay];
const merged = {
...base,
...overlay,
} as DevContainerConfig;
delete merged.extends;

if (base.init || overlay.init) {
merged.init = true;
} else if (base.init === false || overlay.init === false) {
merged.init = false;
}

if (base.privileged || overlay.privileged) {
merged.privileged = true;
} else if (base.privileged === false || overlay.privileged === false) {
merged.privileged = false;
}

assignOrDelete(merged, 'capAdd', unionOrUndefined([base.capAdd, overlay.capAdd]));
assignOrDelete(merged, 'securityOpt', unionOrUndefined([base.securityOpt, overlay.securityOpt]));
assignOrDelete(merged, 'mounts', mergeMounts(metadata));
assignOrDelete(merged, 'forwardPorts', mergeForwardPorts(metadata));
assignOrDelete(merged, 'hostRequirements', mergeHostRequirements(metadata));

const remoteEnv = Object.assign({}, base.remoteEnv, overlay.remoteEnv);
assignOrDelete(merged, 'remoteEnv', Object.keys(remoteEnv).length ? remoteEnv : undefined);
const containerEnv = Object.assign({}, base.containerEnv, overlay.containerEnv);
assignOrDelete(merged, 'containerEnv', Object.keys(containerEnv).length ? containerEnv : undefined);
const portsAttributes = Object.assign({}, base.portsAttributes, overlay.portsAttributes);
assignOrDelete(merged, 'portsAttributes', Object.keys(portsAttributes).length ? portsAttributes : undefined);
const features = Object.assign({}, base.features, overlay.features);
assignOrDelete(merged, 'features', Object.keys(features).length ? features : undefined);
const customizations = Object.assign({}, base.customizations, overlay.customizations);
assignOrDelete(merged, 'customizations', Object.keys(customizations).length ? customizations : undefined);

const runArgs = unionOrUndefined([
'runArgs' in base ? base.runArgs : undefined,
'runArgs' in overlay ? overlay.runArgs : undefined,
]);
if ('runArgs' in merged || runArgs) {
(merged as DevContainerFromImageConfig).runArgs = runArgs;
if (!runArgs) {
delete (merged as DevContainerFromImageConfig).runArgs;
}
}

const runServices = unionOrUndefined([
'dockerComposeFile' in base ? base.runServices : undefined,
'dockerComposeFile' in overlay ? overlay.runServices : undefined,
]);
if ('runServices' in merged || runServices) {
(merged as DevContainerFromDockerComposeConfig).runServices = runServices;
if (!runServices) {
delete (merged as DevContainerFromDockerComposeConfig).runServices;
}
}

return merged;
}

function assignOrDelete<K extends keyof DevContainerConfig>(target: DevContainerConfig, key: K, value: DevContainerConfig[K] | undefined) {
if (value !== undefined) {
target[key] = value;
} else {
delete target[key];
}
}

function mergeForwardPorts(imageMetadata: ImageMetadataEntry[]): (number | string)[] | undefined {
const forwardPorts = [
...new Set(
Expand Down
135 changes: 135 additions & 0 deletions src/test/configContainer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import * as path from 'path';
import { assert } from 'chai';
import { URI } from 'vscode-uri';
import { getCLIHost, loadNativeModule } from '../spec-common/commonUtils';
import { DevContainerConfig, DevContainerFromImageConfig } from '../spec-configuration/configuration';
import { readDevContainerConfigFile } from '../spec-node/configContainer';
import { mergeDevContainerConfigs } from '../spec-node/imageMetadata';
import { Workspace } from '../spec-utils/workspaces';
import { nullLog } from '../spec-utils/log';

const workspace: Workspace = {
isWorkspaceFile: false,
workspaceOrFolderPath: '/foo/bar',
rootFolderPath: '/foo/bar',
configFolderPath: '/foo/bar',
};

async function readConfig(relativePath: string) {
const cliHost = await getCLIHost(process.cwd(), loadNativeModule, false);
const configFile = URI.file(path.resolve(relativePath));
return readDevContainerConfigFile(cliHost, workspace, configFile, false, false, nullLog);
}

describe('readDevContainerConfigFile', function () {
it('can read a basic configuration file', async function () {
const configs = await readConfig('./src/test/configs/example/.devcontainer.json');
assert.isOk(configs);
assert.property(configs, 'config');
assert.isOk(configs?.config.config);

const features = configs?.config.config.features as Record<string, string | boolean | Record<string, string | boolean>>;
assert.hasAllKeys(features, ['ghcr.io/devcontainers/features/github-cli:1']);
});

it('can resolve an "extends" file reference', async function () {
const configs = await readConfig('./src/test/configs/extends/.devcontainer.json');
assert.isOk(configs);
const expectedConfig = {
name: 'Overrides',
image: 'mcr.microsoft.com/devcontainers/base:latest',
forwardPorts: [80, 443],
capAdd: ['SYS_PTRACE', 'NET_ADMIN'],
hostRequirements: {
cpus: 2,
memory: `${8 * 2 ** 30}`,
storage: undefined,
gpu: undefined,
},
remoteEnv: {
FROM_BASE: 'base',
OVERRIDE_ME: 'child',
},
features: {
'ghcr.io/devcontainers/features/docker-in-docker:1': {
version: 'latest',
moby: true,
},
'ghcr.io/devcontainers/features/go:1': {
version: 'latest',
},
},
};

assert.deepEqual(configs?.config.raw as any, expectedConfig);
assert.notProperty(configs?.config.raw as any, 'extends');
});

it('can resolve nested "extends" file references', async function () {
const configs = await readConfig('./src/test/configs/extends/.devcontainer.nested.json');
assert.isOk(configs);
assert.strictEqual(configs?.config.raw.name, 'Nested');
assert.deepEqual(configs?.config.raw.forwardPorts, [80, 443, 2222]);
assert.strictEqual((configs?.config.raw as DevContainerFromImageConfig).image, 'mcr.microsoft.com/devcontainers/base:latest');
});

it('rejects a cyclic "extends" reference', async function () {
try {
await readConfig('./src/test/configs/extends/.devcontainer.cycle-a.json');
assert.fail('expected cyclic extends to throw');
} catch (err: any) {
assert.match(String(err.description || err.message), /cyclic "extends" reference/);
}
});

it('rejects a missing "extends" file', async function () {
try {
await readConfig('./src/test/configs/extends/.devcontainer.missing.json');
assert.fail('expected missing extends to throw');
} catch (err: any) {
assert.match(String(err.description || err.message), /was not found/);
}
});
});

describe('mergeDevContainerConfigs', function () {
it('uses image metadata merge logic for overlapping properties', function () {
const base: DevContainerConfig = {
image: 'mcr.microsoft.com/devcontainers/base:latest',
init: false,
privileged: true,
forwardPorts: [80],
hostRequirements: {
cpus: 4,
memory: '4gb',
},
remoteUser: 'vscode',
onCreateCommand: 'echo base',
};
const overlay: DevContainerConfig = {
image: 'mcr.microsoft.com/devcontainers/javascript-node:latest',
init: true,
forwardPorts: [443],
hostRequirements: {
cpus: 2,
memory: '8gb',
},
onCreateCommand: 'echo overlay',
};

const merged = mergeDevContainerConfigs(base, overlay);
assert.strictEqual((merged as DevContainerFromImageConfig).image, 'mcr.microsoft.com/devcontainers/javascript-node:latest');
assert.strictEqual(merged.init, true);
assert.strictEqual(merged.privileged, true);
assert.deepEqual(merged.forwardPorts, [80, 443]);
assert.strictEqual(merged.hostRequirements?.cpus, 4);
assert.strictEqual(merged.hostRequirements?.memory, `${8 * 2 ** 30}`);
assert.strictEqual(merged.remoteUser, 'vscode');
assert.strictEqual(merged.onCreateCommand, 'echo overlay');
});
});
19 changes: 19 additions & 0 deletions src/test/configs/extends/.devcontainer.base.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "example configuration",
"image": "mcr.microsoft.com/devcontainers/base:latest",
"forwardPorts": [80],
"capAdd": ["SYS_PTRACE"],
"hostRequirements": {
"cpus": 2,
"memory": "8gb"
},
"remoteEnv": {
"FROM_BASE": "base",
"OVERRIDE_ME": "base"
},
"features": {
"ghcr.io/devcontainers/features/go:1": {
"version": "latest"
}
}
}
4 changes: 4 additions & 0 deletions src/test/configs/extends/.devcontainer.cycle-a.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "./.devcontainer.cycle-b.json",
"image": "mcr.microsoft.com/devcontainers/base:latest"
}
4 changes: 4 additions & 0 deletions src/test/configs/extends/.devcontainer.cycle-b.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "./.devcontainer.cycle-a.json",
"name": "cycle"
}
18 changes: 18 additions & 0 deletions src/test/configs/extends/.devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"extends": "./.devcontainer.base.json",
"name": "Overrides",
"forwardPorts": [443],
"capAdd": ["NET_ADMIN"],
"hostRequirements": {
"memory": "4gb"
},
"remoteEnv": {
"OVERRIDE_ME": "child"
},
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:1": {
"version": "latest",
"moby": true
}
}
}
4 changes: 4 additions & 0 deletions src/test/configs/extends/.devcontainer.missing.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "./does-not-exist.json",
"image": "mcr.microsoft.com/devcontainers/base:latest"
}
5 changes: 5 additions & 0 deletions src/test/configs/extends/.devcontainer.nested.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"extends": "./.devcontainer.json",
"name": "Nested",
"forwardPorts": [2222]
}