diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dcbac73a..f27c6f1bc 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, 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] diff --git a/src/spec-configuration/configuration.ts b/src/spec-configuration/configuration.ts index 5995e7e2b..a9728dfcf 100644 --- a/src/spec-configuration/configuration.ts +++ b/src/spec-configuration/configuration.ts @@ -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; @@ -73,6 +75,8 @@ export interface DevContainerFromImageConfig { features?: Record>; overrideFeatureInstallOrder?: string[]; hostRequirements?: HostRequirements; + extends?: string; + extendsMergeMode?: DevContainerExtendsMergeMode; customizations?: Record; } @@ -110,6 +114,8 @@ export type DevContainerFromDockerfileConfig = { features?: Record>; overrideFeatureInstallOrder?: string[]; hostRequirements?: HostRequirements; + extends?: string; + extendsMergeMode?: DevContainerExtendsMergeMode; customizations?: Record; } & ( { @@ -168,6 +174,8 @@ export interface DevContainerFromDockerComposeConfig { features?: Record>; overrideFeatureInstallOrder?: string[]; hostRequirements?: HostRequirements; + extends?: string; + extendsMergeMode?: DevContainerExtendsMergeMode; customizations?: Record; } diff --git a/src/spec-node/configContainer.ts b/src/spec-node/configContainer.ts index 3ee8873ee..e5a6059c8 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,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): 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; + 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); +} + +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..b640b9bfd 100644 --- a/src/spec-node/imageMetadata.ts +++ b/src/spec-node/imageMetadata.ts @@ -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'; @@ -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; +} + +/** + * 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; +} + +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..2e0df7c74 --- /dev/null +++ b/src/test/configContainer.test.ts @@ -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>; + 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/); + }); +}); diff --git a/src/test/configs/extends/.devcontainer.base.json b/src/test/configs/extends/.devcontainer.base.json new file mode 100644 index 000000000..c1c74f20c --- /dev/null +++ b/src/test/configs/extends/.devcontainer.base.json @@ -0,0 +1,13 @@ +{ + "image": "ubuntu:latest", + "forwardPorts": [80], + "capAdd": ["SYS_PTRACE"], + "hostRequirements": { + "cpus": 2, + "memory": "8gb" + }, + "remoteEnv": { + "FROM_BASE": "base", + "OVERRIDE_ME": "base" + } +} 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.invalid-merge.json b/src/test/configs/extends/.devcontainer.invalid-merge.json new file mode 100644 index 000000000..7894ae608 --- /dev/null +++ b/src/test/configs/extends/.devcontainer.invalid-merge.json @@ -0,0 +1,4 @@ +{ + "extends": "./.devcontainer.base.json", + "extendsMergeMode": "deepmerge" +} diff --git a/src/test/configs/extends/.devcontainer.json b/src/test/configs/extends/.devcontainer.json new file mode 100644 index 000000000..b7a3149b3 --- /dev/null +++ b/src/test/configs/extends/.devcontainer.json @@ -0,0 +1,12 @@ +{ + "extends": "./.devcontainer.base.json", + "name": "Overrides", + "forwardPorts": [443], + "capAdd": ["NET_ADMIN"], + "hostRequirements": { + "memory": "4gb" + }, + "remoteEnv": { + "OVERRIDE_ME": "child" + } +} 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] +} diff --git a/src/test/configs/extends/.devcontainer.override.json b/src/test/configs/extends/.devcontainer.override.json new file mode 100644 index 000000000..2db3baf7c --- /dev/null +++ b/src/test/configs/extends/.devcontainer.override.json @@ -0,0 +1,13 @@ +{ + "extends": "./.devcontainer.base.json", + "extendsMergeMode": "override", + "name": "Override merge", + "forwardPorts": [443], + "hostRequirements": { + "memory": "4gb" + }, + "remoteEnv": { + "OVERRIDE_ME": "child" + }, + "init": false +} diff --git a/src/test/imageMetadata.test.ts b/src/test/imageMetadata.test.ts index b45087d58..499bc1ef1 100644 --- a/src/test/imageMetadata.test.ts +++ b/src/test/imageMetadata.test.ts @@ -6,9 +6,9 @@ import * as assert from 'assert'; import * as path from 'path'; import { URI } from 'vscode-uri'; -import { DevContainerConfig, HostGPURequirements } from '../spec-configuration/configuration'; +import { DevContainerConfig, DevContainerFromImageConfig, HostGPURequirements } from '../spec-configuration/configuration'; import { Feature, FeaturesConfig, FeatureSet, Mount } from '../spec-configuration/containerFeaturesConfiguration'; -import { getDevcontainerMetadata, getDevcontainerMetadataLabel, getImageMetadata, getImageMetadataFromContainer, ImageMetadataEntry, imageMetadataLabel, internalGetImageMetadata0, mergeConfiguration } from '../spec-node/imageMetadata'; +import { getDevcontainerMetadata, getDevcontainerMetadataLabel, getImageMetadata, getImageMetadataFromContainer, ImageMetadataEntry, imageMetadataLabel, internalGetImageMetadata0, mergeConfiguration, mergeDevContainerConfigs } from '../spec-node/imageMetadata'; import { SubstitutedConfig } from '../spec-node/utils'; import { ContainerDetails, ImageDetails } from '../spec-shutdown/dockerUtils'; import { nullLog } from '../spec-utils/log'; @@ -559,6 +559,71 @@ describe('Image Metadata', function () { }); }); +describe('mergeDevContainerConfigs', function () { + it('should combine configs using image metadata merge logic', function () { + const base: DevContainerConfig = { + image: 'ubuntu:latest', + init: false, + privileged: true, + forwardPorts: [80], + hostRequirements: { + cpus: 4, + memory: '4gb', + }, + remoteUser: 'vscode', + onCreateCommand: 'echo base', + }; + const overlay: DevContainerConfig = { + image: 'ubuntu:latest', + init: true, + forwardPorts: [443], + hostRequirements: { + cpus: 2, + memory: '8gb', + }, + onCreateCommand: 'echo overlay', + }; + + const merged = mergeDevContainerConfigs(base, overlay); + assert.strictEqual((merged as DevContainerFromImageConfig).image, 'ubuntu:latest'); + assert.strictEqual(merged.init, true); + assert.strictEqual(merged.privileged, true); + assert.deepStrictEqual(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'); + }); + + it('should override configs when merge mode is override', function () { + const base: DevContainerConfig = { + image: 'ubuntu:latest', + init: true, + privileged: true, + forwardPorts: [80], + hostRequirements: { + cpus: 4, + memory: '8gb', + storage: '32gb', + }, + }; + const overlay: DevContainerConfig = { + image: 'ubuntu:latest', + init: false, + forwardPorts: [443], + hostRequirements: { + memory: '4gb', + }, + }; + + const merged = mergeDevContainerConfigs(base, overlay, 'override'); + assert.strictEqual(merged.init, false); + assert.strictEqual(merged.privileged, true); + assert.deepStrictEqual(merged.forwardPorts, [443]); + assert.deepStrictEqual(merged.hostRequirements, { memory: '4gb' }); + }); +}); + function getFeaturesConfig(features: Feature[]): FeaturesConfig { return { featureSets: features.map((feature): FeatureSet => ({