From 2585113820bad8a3d63241d76201b76d95b364a8 Mon Sep 17 00:00:00 2001 From: Samuel Frost Date: Fri, 18 Sep 2026 17:43:37 +0000 Subject: [PATCH] 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. --- CHANGELOG.md | 4 + src/spec-configuration/configuration.ts | 3 + src/spec-node/configContainer.ts | 41 +++++- src/spec-node/imageMetadata.ts | 75 ++++++++++ src/test/configContainer.test.ts | 135 ++++++++++++++++++ .../configs/extends/.devcontainer.base.json | 19 +++ .../extends/.devcontainer.cycle-a.json | 4 + .../extends/.devcontainer.cycle-b.json | 4 + src/test/configs/extends/.devcontainer.json | 18 +++ .../extends/.devcontainer.missing.json | 4 + .../configs/extends/.devcontainer.nested.json | 5 + 11 files changed, 308 insertions(+), 4 deletions(-) create mode 100644 src/test/configContainer.test.ts create mode 100644 src/test/configs/extends/.devcontainer.base.json create mode 100644 src/test/configs/extends/.devcontainer.cycle-a.json create mode 100644 src/test/configs/extends/.devcontainer.cycle-b.json create mode 100644 src/test/configs/extends/.devcontainer.json create mode 100644 src/test/configs/extends/.devcontainer.missing.json create mode 100644 src/test/configs/extends/.devcontainer.nested.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dcbac73a..0b6418903 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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] diff --git a/src/spec-configuration/configuration.ts b/src/spec-configuration/configuration.ts index 5995e7e2b..ab5f1c9fa 100644 --- a/src/spec-configuration/configuration.ts +++ b/src/spec-configuration/configuration.ts @@ -73,6 +73,7 @@ export interface DevContainerFromImageConfig { features?: Record>; overrideFeatureInstallOrder?: string[]; hostRequirements?: HostRequirements; + extends?: string; customizations?: Record; } @@ -110,6 +111,7 @@ export type DevContainerFromDockerfileConfig = { features?: Record>; overrideFeatureInstallOrder?: string[]; hostRequirements?: HostRequirements; + extends?: string; customizations?: Record; } & ( { @@ -168,6 +170,7 @@ export interface DevContainerFromDockerComposeConfig { features?: Record>; overrideFeatureInstallOrder?: string[]; hostRequirements?: HostRequirements; + extends?: string; customizations?: Record; } diff --git a/src/spec-node/configContainer.ts b/src/spec-node/configContainer.ts index 3ee8873ee..39b7dc263 100644 --- a/src/spec-node/configContainer.ts +++ b/src/spec-node/configContainer.ts @@ -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>): Promise { @@ -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): Promise { + 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({ diff --git a/src/spec-node/imageMetadata.ts b/src/spec-node/imageMetadata.ts index 3f10914af..53b5ebb53 100644 --- a/src/spec-node/imageMetadata.ts +++ b/src/spec-node/imageMetadata.ts @@ -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(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( diff --git a/src/test/configContainer.test.ts b/src/test/configContainer.test.ts new file mode 100644 index 000000000..608da9410 --- /dev/null +++ b/src/test/configContainer.test.ts @@ -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>; + 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'); + }); +}); diff --git a/src/test/configs/extends/.devcontainer.base.json b/src/test/configs/extends/.devcontainer.base.json new file mode 100644 index 000000000..7f799fa84 --- /dev/null +++ b/src/test/configs/extends/.devcontainer.base.json @@ -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" + } + } +} diff --git a/src/test/configs/extends/.devcontainer.cycle-a.json b/src/test/configs/extends/.devcontainer.cycle-a.json new file mode 100644 index 000000000..4918aac06 --- /dev/null +++ b/src/test/configs/extends/.devcontainer.cycle-a.json @@ -0,0 +1,4 @@ +{ + "extends": "./.devcontainer.cycle-b.json", + "image": "mcr.microsoft.com/devcontainers/base:latest" +} diff --git a/src/test/configs/extends/.devcontainer.cycle-b.json b/src/test/configs/extends/.devcontainer.cycle-b.json new file mode 100644 index 000000000..250d6be64 --- /dev/null +++ b/src/test/configs/extends/.devcontainer.cycle-b.json @@ -0,0 +1,4 @@ +{ + "extends": "./.devcontainer.cycle-a.json", + "name": "cycle" +} diff --git a/src/test/configs/extends/.devcontainer.json b/src/test/configs/extends/.devcontainer.json new file mode 100644 index 000000000..58cbc5815 --- /dev/null +++ b/src/test/configs/extends/.devcontainer.json @@ -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 + } + } +} diff --git a/src/test/configs/extends/.devcontainer.missing.json b/src/test/configs/extends/.devcontainer.missing.json new file mode 100644 index 000000000..2851c8341 --- /dev/null +++ b/src/test/configs/extends/.devcontainer.missing.json @@ -0,0 +1,4 @@ +{ + "extends": "./does-not-exist.json", + "image": "mcr.microsoft.com/devcontainers/base:latest" +} diff --git a/src/test/configs/extends/.devcontainer.nested.json b/src/test/configs/extends/.devcontainer.nested.json new file mode 100644 index 000000000..261e56bd8 --- /dev/null +++ b/src/test/configs/extends/.devcontainer.nested.json @@ -0,0 +1,5 @@ +{ + "extends": "./.devcontainer.json", + "name": "Nested", + "forwardPorts": [2222] +}