Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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