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
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, with optional `extendsMergeMode` (`combine` uses image metadata merge logic; `override` uses `{ ...base, ...overlay }` so each set top-level property from the child fully replaces the inherited value). (https://github.com/devcontainers/spec/issues/22, https://github.com/devcontainers/cli/pull/311)

## August 2026

### [0.89.0]
Expand Down
8 changes: 8 additions & 0 deletions src/spec-configuration/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export type UserEnvProbe = 'none' | 'loginInteractiveShell' | 'interactiveShell'

export type DevContainerConfigCommand = 'initializeCommand' | 'onCreateCommand' | 'updateContentCommand' | 'postCreateCommand' | 'postStartCommand' | 'postAttachCommand';

export type DevContainerExtendsMergeMode = 'combine' | 'override';

export interface HostGPURequirements {
cores?: number;
memory?: string;
Expand Down Expand Up @@ -73,6 +75,8 @@ export interface DevContainerFromImageConfig {
features?: Record<string, string | boolean | Record<string, string | boolean>>;
overrideFeatureInstallOrder?: string[];
hostRequirements?: HostRequirements;
extends?: string;
extendsMergeMode?: DevContainerExtendsMergeMode;
customizations?: Record<string, any>;
}

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

Expand Down
46 changes: 42 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,53 @@ 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;
const extendsMergeMode = updated.extendsMergeMode ?? 'combine';
delete updated.extends;
delete updated.extendsMergeMode;
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 (extendsMergeMode !== 'combine' && extendsMergeMode !== 'override') {
throw new ContainerError({ description: `"extendsMergeMode" in (${uriToFsPath(configUri, cliHost.platform)}) must be "combine" or "override".` });
}
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, extendsMergeMode);
}

Comment thread
SamuelFrost marked this conversation as resolved.
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
96 changes: 95 additions & 1 deletion src/spec-node/imageMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import { ContainerError } from '../spec-common/errors';
import { PlatformInfo } from '../spec-common/commonUtils';
import { LifecycleCommand, LifecycleHooksInstallMap } from '../spec-common/injectHeadless';
import { DevContainerConfig, DevContainerConfigCommand, DevContainerFromDockerComposeConfig, DevContainerFromDockerfileConfig, DevContainerFromImageConfig, getDockerComposeFilePaths, getDockerfilePath, HostGPURequirements, HostRequirements, isDockerFileConfig, PortAttributes, UserEnvProbe } from '../spec-configuration/configuration';
import { DevContainerConfig, DevContainerConfigCommand, DevContainerExtendsMergeMode, DevContainerFromDockerComposeConfig, DevContainerFromDockerfileConfig, DevContainerFromImageConfig, getDockerComposeFilePaths, getDockerfilePath, HostGPURequirements, HostRequirements, isDockerFileConfig, PortAttributes, UserEnvProbe } from '../spec-configuration/configuration';
import { Feature, FeaturesConfig, Mount, parseMount, SchemaFeatureLifecycleHooks } from '../spec-configuration/containerFeaturesConfiguration';
import { ContainerDetails, DockerCLIParameters, ImageDetails } from '../spec-shutdown/dockerUtils';
import { Log, LogLevel } from '../spec-utils/log';
Expand Down Expand Up @@ -199,6 +199,100 @@ 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, extendsMergeMode: DevContainerExtendsMergeMode = 'combine'): DevContainerConfig {
if (extendsMergeMode === 'override') {
return mergeDevContainerConfigsOverride(base, overlay);
}

const metadata: ImageMetadataEntry[] = [base, overlay];
const merged = {
...base,
...overlay,
} as DevContainerConfig;
delete merged.extends;
delete merged.extendsMergeMode;

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;
}
Comment thread
SamuelFrost marked this conversation as resolved.

/**
* Overlay-style merge: each top-level property from the overlay replaces the base value when set;
* omitted overlay keys keep the inherited base value ({ ...base, ...overlay }).
*/
function mergeDevContainerConfigsOverride(base: DevContainerConfig, overlay: DevContainerConfig): DevContainerConfig {
const merged = {
...base,
...overlay,
} as DevContainerConfig;
delete merged.extends;
delete merged.extendsMergeMode;
return merged;
}
Comment thread
SamuelFrost marked this conversation as resolved.

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
96 changes: 96 additions & 0 deletions src/test/configContainer.test.ts
Comment thread
SamuelFrost marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/*---------------------------------------------------------------------------------------------
* 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 { DevContainerFromImageConfig } from '../spec-configuration/configuration';
import { readDevContainerConfigFile } from '../spec-node/configContainer';
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);
}

async function expectReadConfigError(relativePath: string, pattern: RegExp) {
try {
await readConfig(relativePath);
assert.fail('expected read to throw');
} catch (err: any) {
assert.match(String(err.description || err.message), pattern);
}
}

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 raw = configs?.config.raw as DevContainerFromImageConfig;
assert.strictEqual(raw.name, 'Overrides');
assert.strictEqual(raw.image, 'ubuntu:latest');
assert.deepEqual(raw.forwardPorts, [80, 443]);
assert.deepEqual(raw.capAdd, ['SYS_PTRACE', 'NET_ADMIN']);
assert.strictEqual(raw.hostRequirements?.cpus, 2);
assert.strictEqual(raw.hostRequirements?.memory, `${8 * 2 ** 30}`);
assert.deepEqual(raw.remoteEnv, { FROM_BASE: 'base', OVERRIDE_ME: 'child' });
assert.notProperty(raw, 'extends');
assert.notProperty(raw, 'extendsMergeMode');
});

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, 'ubuntu:latest');
});

it('rejects a cyclic "extends" reference', async function () {
await expectReadConfigError('./src/test/configs/extends/.devcontainer.cycle-a.json', /cyclic "extends" reference/);
});

it('can resolve "extends" with extendsMergeMode override', async function () {
const configs = await readConfig('./src/test/configs/extends/.devcontainer.override.json');
assert.isOk(configs);
const raw = configs?.config.raw as DevContainerFromImageConfig;
assert.strictEqual(raw.name, 'Override merge');
assert.strictEqual(raw.image, 'ubuntu:latest');
assert.deepEqual(raw.forwardPorts, [443]);
assert.strictEqual(raw.init, false);
assert.deepEqual(raw.remoteEnv, { OVERRIDE_ME: 'child' });
assert.deepEqual(raw.hostRequirements, { memory: '4gb' });
assert.notProperty(raw, 'extends');
assert.notProperty(raw, 'extendsMergeMode');
});

it('rejects an invalid "extendsMergeMode" value', async function () {
await expectReadConfigError('./src/test/configs/extends/.devcontainer.invalid-merge.json', /extendsMergeMode.*combine.*override/);
});

it('rejects a missing "extends" file', async function () {
await expectReadConfigError('./src/test/configs/extends/.devcontainer.missing.json', /was not found/);
});
});
13 changes: 13 additions & 0 deletions src/test/configs/extends/.devcontainer.base.json
Comment thread
SamuelFrost marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"image": "ubuntu:latest",
"forwardPorts": [80],
"capAdd": ["SYS_PTRACE"],
"hostRequirements": {
"cpus": 2,
"memory": "8gb"
},
"remoteEnv": {
"FROM_BASE": "base",
"OVERRIDE_ME": "base"
}
}
4 changes: 4 additions & 0 deletions src/test/configs/extends/.devcontainer.cycle-a.json
Comment thread
SamuelFrost marked this conversation as resolved.
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
Comment thread
SamuelFrost marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "./.devcontainer.cycle-a.json",
"name": "cycle"
}
4 changes: 4 additions & 0 deletions src/test/configs/extends/.devcontainer.invalid-merge.json
Comment thread
SamuelFrost marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "./.devcontainer.base.json",
"extendsMergeMode": "deepmerge"
}
12 changes: 12 additions & 0 deletions src/test/configs/extends/.devcontainer.json
Comment thread
SamuelFrost marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"extends": "./.devcontainer.base.json",
"name": "Overrides",
"forwardPorts": [443],
"capAdd": ["NET_ADMIN"],
"hostRequirements": {
"memory": "4gb"
},
"remoteEnv": {
"OVERRIDE_ME": "child"
}
}
4 changes: 4 additions & 0 deletions src/test/configs/extends/.devcontainer.missing.json
Comment thread
SamuelFrost marked this conversation as resolved.
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
Comment thread
SamuelFrost marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"extends": "./.devcontainer.json",
"name": "Nested",
"forwardPorts": [2222]
}
13 changes: 13 additions & 0 deletions src/test/configs/extends/.devcontainer.override.json
Comment thread
SamuelFrost marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"extends": "./.devcontainer.base.json",
"extendsMergeMode": "override",
"name": "Override merge",
"forwardPorts": [443],
"hostRequirements": {
"memory": "4gb"
},
"remoteEnv": {
"OVERRIDE_ME": "child"
},
"init": false
}
Loading