Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
3 changes: 2 additions & 1 deletion .github/workflows/dev-containers.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,12 @@ jobs:
"src/test/cli.podman.test.ts",
"src/test/cli.test.ts",
"src/test/cli.up.test.ts",
"src/test/cli.extends.up.test.ts",
"src/test/httpOCIRegistry.test.ts",
"src/test/imageMetadata.test.ts",
"src/test/container-features/containerFeaturesOCIPush.test.ts",
# Run all except the above:
"--exclude src/test/container-features/containerFeaturesOrder.test.ts --exclude src/test/container-features/registryCompatibilityOCI.test.ts --exclude src/test/container-features/containerFeaturesOCIPush.test.ts --exclude src/test/container-features/e2e.test.ts --exclude src/test/container-features/featuresCLICommands.test.ts --exclude src/test/cli.build.test.ts --exclude src/test/cli.exec.buildKit.1.test.ts --exclude src/test/cli.exec.buildKit.2.test.ts --exclude src/test/cli.exec.nonBuildKit.1.test.ts --exclude src/test/cli.exec.nonBuildKit.2.test.ts --exclude src/test/cli.podman.test.ts --exclude src/test/cli.test.ts --exclude src/test/cli.up.test.ts --exclude src/test/httpOCIRegistry.test.ts --exclude src/test/imageMetadata.test.ts 'src/test/**/*.test.ts'",
"--exclude src/test/container-features/containerFeaturesOrder.test.ts --exclude src/test/container-features/registryCompatibilityOCI.test.ts --exclude src/test/container-features/containerFeaturesOCIPush.test.ts --exclude src/test/container-features/e2e.test.ts --exclude src/test/container-features/featuresCLICommands.test.ts --exclude src/test/cli.build.test.ts --exclude src/test/cli.exec.buildKit.1.test.ts --exclude src/test/cli.exec.buildKit.2.test.ts --exclude src/test/cli.exec.nonBuildKit.1.test.ts --exclude src/test/cli.exec.nonBuildKit.2.test.ts --exclude src/test/cli.extends.up.test.ts --exclude src/test/cli.podman.test.ts --exclude src/test/cli.test.ts --exclude src/test/cli.up.test.ts --exclude src/test/httpOCIRegistry.test.ts --exclude src/test/imageMetadata.test.ts 'src/test/**/*.test.ts'",
]
steps:
- name: Checkout
Expand Down
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` replaces arrays/scalars from the child). (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
110 changes: 109 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,114 @@ 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: scalars and arrays from the overlay replace the base when set;
* object maps and `hostRequirements` are shallow-merged with overlay keys winning.
*/
function mergeDevContainerConfigsOverride(base: DevContainerConfig, overlay: DevContainerConfig): DevContainerConfig {
const merged = {
...base,
...overlay,
} as DevContainerConfig;
delete merged.extends;
delete merged.extendsMergeMode;

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 hostRequirements = Object.assign({}, base.hostRequirements, overlay.hostRequirements);
assignOrDelete(merged, 'hostRequirements', Object.keys(hostRequirements).length ? hostRequirements : undefined);

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
80 changes: 80 additions & 0 deletions src/test/cli.extends.up.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import * as assert from 'assert';
import * as path from 'path';
import { devContainerDown, devContainerUp, shellExec } from './testUtils';

const pkg = require('../../package.json');

describe('Dev Containers CLI extends', function () {
this.timeout('240s');

const tmp = path.relative(process.cwd(), path.join(__dirname, 'tmp'));
const cli = `npx --prefix ${tmp} devcontainer`;
const testFolder = path.join(__dirname, 'configs/extends-up');
const overrideTestFolder = path.join(__dirname, 'configs/extends-up-override');

before('Install packaged CLI', async () => {
await shellExec(`rm -rf ${tmp}/node_modules`);
await shellExec(`mkdir -p ${tmp}`);
await shellExec(`npm --prefix ${tmp} install devcontainers-cli-${pkg.version}.tgz`);
});

describe('Command up with extends', () => {
describe('combine (default extendsMergeMode)', () => {
let containerId: string | null = null;

before(async () => {
const res = await shellExec(
`${cli} up --workspace-folder ${testFolder} --buildkit=never --include-configuration --include-merged-configuration`,
);
const response = JSON.parse(res.stdout);
assert.equal(response.outcome, 'success');
containerId = response.containerId;
assert.ok(containerId, 'Container id not found.');
assert.equal(response.configuration?.name, 'extends-up-combine');
assert.equal(response.configuration?.remoteEnv?.FROM_BASE, 'base');
assert.equal(response.configuration?.remoteEnv?.OVERRIDE_ME, 'child');
assert.equal(response.configuration?.remoteEnv?.EXTENDS_UP, 'combine');
assert.notProperty(response.configuration ?? {}, 'extends');
assert.notProperty(response.configuration ?? {}, 'extendsMergeMode');
});

after(async () => await devContainerDown({ containerId }));

it('starts a container with merged remoteEnv from the extends chain', async () => {
const env = await shellExec(`docker exec ${containerId} printenv OVERRIDE_ME`);
assert.equal(env.stdout.trim(), 'child');
const marker = await shellExec(`docker exec ${containerId} printenv EXTENDS_UP`);
assert.equal(marker.stdout.trim(), 'combine');
});
});

describe('override (extendsMergeMode)', () => {
let containerId: string | null = null;

before(async () => {
const res = await shellExec(
`${cli} up --workspace-folder ${overrideTestFolder} --buildkit=never --include-configuration`,
);
const response = JSON.parse(res.stdout);
assert.equal(response.outcome, 'success');
containerId = response.containerId;
assert.ok(containerId, 'Container id not found.');
assert.equal(response.configuration?.name, 'extends-up-override');
assert.equal(response.configuration?.remoteEnv?.EXTENDS_UP, 'override');
assert.deepEqual(response.configuration?.forwardPorts, [9999]);
});

after(async () => await devContainerDown({ containerId }));

it('starts a container using override merge semantics', async () => {
const env = await shellExec(`docker exec ${containerId} printenv EXTENDS_UP`);
assert.equal(env.stdout.trim(), 'override');
});
});
});
});
Loading