From 2585113820bad8a3d63241d76201b76d95b364a8 Mon Sep 17 00:00:00 2001 From: Samuel Frost Date: Fri, 18 Sep 2026 17:43:37 +0000 Subject: [PATCH 1/5] 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] +} From 7c7d1cadd96875bba0c41fb4ce5454672fd72cd7 Mon Sep 17 00:00:00 2001 From: Samuel Frost Date: Thu, 24 Sep 2026 06:24:09 +0000 Subject: [PATCH 2/5] Add extendsMergeMode for combine vs override extends merging Optional extendsMergeMode on devcontainer.json selects image-metadata combine (default) or overlay-style override when resolving extends. --- CHANGELOG.md | 2 +- src/spec-configuration/configuration.ts | 5 ++++ src/spec-node/configContainer.ts | 7 ++++- src/spec-node/imageMetadata.ts | 37 +++++++++++++++++++++++-- 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6418903..9647cad45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ 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) +- 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 diff --git a/src/spec-configuration/configuration.ts b/src/spec-configuration/configuration.ts index ab5f1c9fa..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; @@ -74,6 +76,7 @@ export interface DevContainerFromImageConfig { overrideFeatureInstallOrder?: string[]; hostRequirements?: HostRequirements; extends?: string; + extendsMergeMode?: DevContainerExtendsMergeMode; customizations?: Record; } @@ -112,6 +115,7 @@ export type DevContainerFromDockerfileConfig = { overrideFeatureInstallOrder?: string[]; hostRequirements?: HostRequirements; extends?: string; + extendsMergeMode?: DevContainerExtendsMergeMode; customizations?: Record; } & ( { @@ -171,6 +175,7 @@ export interface DevContainerFromDockerComposeConfig { 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 39b7dc263..e5a6059c8 100644 --- a/src/spec-node/configContainer.ts +++ b/src/spec-node/configContainer.ts @@ -99,13 +99,18 @@ async function readDevContainerConfigObject(cliHost: CLIHost, configUri: URI, se } 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.` }); } @@ -115,7 +120,7 @@ async function readDevContainerConfigObject(cliHost: CLIHost, configUri: URI, se 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); + 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) { diff --git a/src/spec-node/imageMetadata.ts b/src/spec-node/imageMetadata.ts index 53b5ebb53..ea9a16249 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'; @@ -204,13 +204,18 @@ export function mergeConfiguration(config: DevContainerConfig, imageMetadata: Im * (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 { +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; @@ -266,6 +271,34 @@ export function mergeDevContainerConfigs(base: DevContainerConfig, overlay: DevC return merged; } +/** + * 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; +} + function assignOrDelete(target: DevContainerConfig, key: K, value: DevContainerConfig[K] | undefined) { if (value !== undefined) { target[key] = value; From ff4be89762ad448696e170a491b8b598ae2dad6b Mon Sep 17 00:00:00 2001 From: Samuel Frost Date: Thu, 24 Sep 2026 06:24:12 +0000 Subject: [PATCH 3/5] Add extends and extendsMergeMode integration tests Cover override merge in configContainer tests and devcontainer up against the packaged CLI, with lightweight ubuntu fixtures and CI matrix entry. --- .github/workflows/dev-containers.yml | 3 +- src/test/cli.extends.up.test.ts | 80 +++++++++++++++++++ src/test/configContainer.test.ts | 54 +++++++++++++ .../extends-up-override/.devcontainer.json | 11 +++ .../extends-up/.devcontainer.base.json | 8 ++ .../configs/extends-up/.devcontainer.json | 8 ++ .../extends/.devcontainer.invalid-merge.json | 4 + .../extends/.devcontainer.override.json | 19 +++++ src/test/testUtils.ts | 5 +- 9 files changed, 189 insertions(+), 3 deletions(-) create mode 100644 src/test/cli.extends.up.test.ts create mode 100644 src/test/configs/extends-up-override/.devcontainer.json create mode 100644 src/test/configs/extends-up/.devcontainer.base.json create mode 100644 src/test/configs/extends-up/.devcontainer.json create mode 100644 src/test/configs/extends/.devcontainer.invalid-merge.json create mode 100644 src/test/configs/extends/.devcontainer.override.json diff --git a/.github/workflows/dev-containers.yml b/.github/workflows/dev-containers.yml index 2f763a1bb..312dc5aef 100644 --- a/.github/workflows/dev-containers.yml +++ b/.github/workflows/dev-containers.yml @@ -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 diff --git a/src/test/cli.extends.up.test.ts b/src/test/cli.extends.up.test.ts new file mode 100644 index 000000000..1a336a420 --- /dev/null +++ b/src/test/cli.extends.up.test.ts @@ -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'); + }); + }); + }); +}); diff --git a/src/test/configContainer.test.ts b/src/test/configContainer.test.ts index 608da9410..95b904377 100644 --- a/src/test/configContainer.test.ts +++ b/src/test/configContainer.test.ts @@ -87,6 +87,30 @@ describe('readDevContainerConfigFile', function () { } }); + 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.deepEqual(raw.forwardPorts, [443]); + assert.strictEqual(raw.init, false); + assert.strictEqual(raw.hostRequirements?.cpus, 2); + assert.strictEqual(raw.hostRequirements?.memory, '4gb'); + assert.notProperty(raw as any, 'extends'); + assert.notProperty(raw as any, 'extendsMergeMode'); + }); + + it('rejects an invalid "extendsMergeMode" value', async function () { + const cliHost = await getCLIHost(process.cwd(), loadNativeModule, false); + const configFile = URI.file(path.resolve('./src/test/configs/extends/.devcontainer.invalid-merge.json')); + try { + await readDevContainerConfigFile(cliHost, workspace, configFile, false, false, nullLog); + assert.fail('expected invalid extendsMergeMode to throw'); + } catch (err: any) { + assert.match(String(err.description || err.message), /extendsMergeMode.*combine.*override/); + } + }); + it('rejects a missing "extends" file', async function () { try { await readConfig('./src/test/configs/extends/.devcontainer.missing.json'); @@ -132,4 +156,34 @@ describe('mergeDevContainerConfigs', function () { assert.strictEqual(merged.remoteUser, 'vscode'); assert.strictEqual(merged.onCreateCommand, 'echo overlay'); }); + + it('uses override merge when extendsMergeMode is override', function () { + const base: DevContainerConfig = { + image: 'mcr.microsoft.com/devcontainers/base:latest', + init: true, + privileged: true, + forwardPorts: [80], + hostRequirements: { + cpus: 4, + memory: '8gb', + storage: '32gb', + }, + }; + const overlay: DevContainerConfig = { + image: 'mcr.microsoft.com/devcontainers/javascript-node: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.deepEqual(merged.forwardPorts, [443]); + assert.strictEqual(merged.hostRequirements?.cpus, 4); + assert.strictEqual(merged.hostRequirements?.memory, '4gb'); + assert.strictEqual(merged.hostRequirements?.storage, '32gb'); + }); }); diff --git a/src/test/configs/extends-up-override/.devcontainer.json b/src/test/configs/extends-up-override/.devcontainer.json new file mode 100644 index 000000000..53041d0bd --- /dev/null +++ b/src/test/configs/extends-up-override/.devcontainer.json @@ -0,0 +1,11 @@ +{ + "extends": "../extends-up/.devcontainer.base.json", + "extendsMergeMode": "override", + "name": "extends-up-override", + "forwardPorts": [9999], + "remoteEnv": { + "OVERRIDE_ME": "child", + "EXTENDS_UP": "override" + }, + "init": false +} diff --git a/src/test/configs/extends-up/.devcontainer.base.json b/src/test/configs/extends-up/.devcontainer.base.json new file mode 100644 index 000000000..b1de7ec48 --- /dev/null +++ b/src/test/configs/extends-up/.devcontainer.base.json @@ -0,0 +1,8 @@ +{ + "image": "ubuntu:latest", + "remoteEnv": { + "FROM_BASE": "base", + "OVERRIDE_ME": "base" + }, + "runArgs": ["-e", "EXTENDS_UP_BASE=1"] +} diff --git a/src/test/configs/extends-up/.devcontainer.json b/src/test/configs/extends-up/.devcontainer.json new file mode 100644 index 000000000..8c9dd3078 --- /dev/null +++ b/src/test/configs/extends-up/.devcontainer.json @@ -0,0 +1,8 @@ +{ + "extends": "./.devcontainer.base.json", + "name": "extends-up-combine", + "remoteEnv": { + "OVERRIDE_ME": "child", + "EXTENDS_UP": "combine" + } +} 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.override.json b/src/test/configs/extends/.devcontainer.override.json new file mode 100644 index 000000000..fa7520d98 --- /dev/null +++ b/src/test/configs/extends/.devcontainer.override.json @@ -0,0 +1,19 @@ +{ + "extends": "./.devcontainer.base.json", + "extendsMergeMode": "override", + "name": "Override merge", + "forwardPorts": [443], + "hostRequirements": { + "memory": "4gb" + }, + "remoteEnv": { + "OVERRIDE_ME": "child" + }, + "init": false, + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:1": { + "version": "latest", + "moby": true + } + } +} diff --git a/src/test/testUtils.ts b/src/test/testUtils.ts index 1eaaded55..9e1c2a723 100644 --- a/src/test/testUtils.ts +++ b/src/test/testUtils.ts @@ -89,14 +89,15 @@ export async function shellPtyExec(command: string, options: { stdin?: string } }).then(res => ({ code: 0, ...res }), error => error); } -export async function devContainerUp(cli: string, workspaceFolder: string, options?: { cwd?: string; useBuildKit?: boolean; userDataFolder?: string; logLevel?: string; extraArgs?: string; prefix?: string; env?: NodeJS.ProcessEnv }): Promise { +export async function devContainerUp(cli: string, workspaceFolder: string, options?: { cwd?: string; useBuildKit?: boolean; userDataFolder?: string; logLevel?: string; config?: string; extraArgs?: string; prefix?: string; env?: NodeJS.ProcessEnv }): Promise { const buildkitOption = (options?.useBuildKit ?? false) ? '' : ' --buildkit=never'; const userDataFolderOption = (options?.userDataFolder ?? false) ? ` --user-data-folder=${options?.userDataFolder}` : ''; const logLevelOption = (options?.logLevel ?? false) ? ` --log-level ${options?.logLevel}` : ''; + const configOption = (options?.config ?? false) ? ` --config ${options.config}` : ''; const extraArgs = (options?.extraArgs ?? false) ? ` ${options?.extraArgs}` : ''; const prefix = (options?.prefix ?? false) ? `${options?.prefix} ` : ''; const shellExecOptions = { cwd: options?.cwd, env: options?.env }; - const res = await shellExec(`${prefix}${cli} up --workspace-folder ${workspaceFolder}${buildkitOption}${userDataFolderOption}${extraArgs} ${logLevelOption}`, shellExecOptions); + const res = await shellExec(`${prefix}${cli} up --workspace-folder ${workspaceFolder}${configOption}${buildkitOption}${userDataFolderOption}${extraArgs} ${logLevelOption}`, shellExecOptions); const response = JSON.parse(res.stdout); assert.equal(response.outcome, 'success'); const { outcome, containerId, composeProjectName } = response as UpResult; From c4e9697913b35e4ff02152e0a53e6e2a428f9f92 Mon Sep 17 00:00:00 2001 From: Samuel Frost Date: Thu, 24 Sep 2026 07:16:55 +0000 Subject: [PATCH 4/5] Use spread semantics for extendsMergeMode override Override merge now fully replaces each set top-level property from the child config instead of shallow-merging object maps and hostRequirements. --- CHANGELOG.md | 2 +- src/spec-node/imageMetadata.ts | 18 ++---------------- src/test/cli.extends.up.test.ts | 5 ++++- src/test/configContainer.test.ts | 14 +++++++++----- 4 files changed, 16 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9647cad45..f27c6f1bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ 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) +- 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 diff --git a/src/spec-node/imageMetadata.ts b/src/spec-node/imageMetadata.ts index ea9a16249..b640b9bfd 100644 --- a/src/spec-node/imageMetadata.ts +++ b/src/spec-node/imageMetadata.ts @@ -272,8 +272,8 @@ export function mergeDevContainerConfigs(base: DevContainerConfig, overlay: DevC } /** - * 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. + * 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 = { @@ -282,20 +282,6 @@ function mergeDevContainerConfigsOverride(base: DevContainerConfig, overlay: Dev } 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; } diff --git a/src/test/cli.extends.up.test.ts b/src/test/cli.extends.up.test.ts index 1a336a420..7e8e9a693 100644 --- a/src/test/cli.extends.up.test.ts +++ b/src/test/cli.extends.up.test.ts @@ -65,7 +65,10 @@ describe('Dev Containers CLI extends', function () { 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?.remoteEnv, { + OVERRIDE_ME: 'child', + EXTENDS_UP: 'override', + }); assert.deepEqual(response.configuration?.forwardPorts, [9999]); }); diff --git a/src/test/configContainer.test.ts b/src/test/configContainer.test.ts index 95b904377..b842b97e1 100644 --- a/src/test/configContainer.test.ts +++ b/src/test/configContainer.test.ts @@ -94,8 +94,14 @@ describe('readDevContainerConfigFile', function () { assert.strictEqual(raw.name, 'Override merge'); assert.deepEqual(raw.forwardPorts, [443]); assert.strictEqual(raw.init, false); - assert.strictEqual(raw.hostRequirements?.cpus, 2); - assert.strictEqual(raw.hostRequirements?.memory, '4gb'); + assert.deepEqual(raw.remoteEnv, { OVERRIDE_ME: 'child' }); + assert.deepEqual(raw.features, { + 'ghcr.io/devcontainers/features/docker-in-docker:1': { + version: 'latest', + moby: true, + }, + }); + assert.deepEqual(raw.hostRequirements, { memory: '4gb' }); assert.notProperty(raw as any, 'extends'); assert.notProperty(raw as any, 'extendsMergeMode'); }); @@ -182,8 +188,6 @@ describe('mergeDevContainerConfigs', function () { assert.strictEqual(merged.init, false); assert.strictEqual(merged.privileged, true); assert.deepEqual(merged.forwardPorts, [443]); - assert.strictEqual(merged.hostRequirements?.cpus, 4); - assert.strictEqual(merged.hostRequirements?.memory, '4gb'); - assert.strictEqual(merged.hostRequirements?.storage, '32gb'); + assert.deepEqual(merged.hostRequirements, { memory: '4gb' }); }); }); From 6302f11bec4187cf04a84d7eb0f762e251d86ff7 Mon Sep 17 00:00:00 2001 From: Samuel Frost Date: Thu, 24 Sep 2026 08:37:52 +0000 Subject: [PATCH 5/5] Refine extends tests with fixtures and unit merge coverage. Remove extends up e2e tests and CI matrix entry; slim extends fixtures and colocate mergeDevContainerConfigs tests with imageMetadata. --- .github/workflows/dev-containers.yml | 3 +- src/test/cli.extends.up.test.ts | 83 ---------- src/test/configContainer.test.ts | 151 ++++-------------- .../extends-up-override/.devcontainer.json | 11 -- .../extends-up/.devcontainer.base.json | 8 - .../configs/extends-up/.devcontainer.json | 8 - .../configs/extends/.devcontainer.base.json | 8 +- src/test/configs/extends/.devcontainer.json | 6 - .../extends/.devcontainer.override.json | 8 +- src/test/imageMetadata.test.ts | 69 +++++++- src/test/testUtils.ts | 5 +- 11 files changed, 99 insertions(+), 261 deletions(-) delete mode 100644 src/test/cli.extends.up.test.ts delete mode 100644 src/test/configs/extends-up-override/.devcontainer.json delete mode 100644 src/test/configs/extends-up/.devcontainer.base.json delete mode 100644 src/test/configs/extends-up/.devcontainer.json diff --git a/.github/workflows/dev-containers.yml b/.github/workflows/dev-containers.yml index 312dc5aef..2f763a1bb 100644 --- a/.github/workflows/dev-containers.yml +++ b/.github/workflows/dev-containers.yml @@ -61,12 +61,11 @@ 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.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'", + "--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'", ] steps: - name: Checkout diff --git a/src/test/cli.extends.up.test.ts b/src/test/cli.extends.up.test.ts deleted file mode 100644 index 7e8e9a693..000000000 --- a/src/test/cli.extends.up.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * 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.deepEqual(response.configuration?.remoteEnv, { - OVERRIDE_ME: 'child', - 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'); - }); - }); - }); -}); diff --git a/src/test/configContainer.test.ts b/src/test/configContainer.test.ts index b842b97e1..2e0df7c74 100644 --- a/src/test/configContainer.test.ts +++ b/src/test/configContainer.test.ts @@ -7,9 +7,8 @@ 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 { 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'; @@ -26,6 +25,15 @@ async function readConfig(relativePath: string) { 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'); @@ -40,34 +48,16 @@ describe('readDevContainerConfigFile', function () { 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'); + 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 () { @@ -75,16 +65,11 @@ describe('readDevContainerConfigFile', function () { 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'); + assert.strictEqual((configs?.config.raw as DevContainerFromImageConfig).image, 'ubuntu: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/); - } + await expectReadConfigError('./src/test/configs/extends/.devcontainer.cycle-a.json', /cyclic "extends" reference/); }); it('can resolve "extends" with extendsMergeMode override', async function () { @@ -92,102 +77,20 @@ describe('readDevContainerConfigFile', function () { 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.features, { - 'ghcr.io/devcontainers/features/docker-in-docker:1': { - version: 'latest', - moby: true, - }, - }); assert.deepEqual(raw.hostRequirements, { memory: '4gb' }); - assert.notProperty(raw as any, 'extends'); - assert.notProperty(raw as any, 'extendsMergeMode'); + assert.notProperty(raw, 'extends'); + assert.notProperty(raw, 'extendsMergeMode'); }); it('rejects an invalid "extendsMergeMode" value', async function () { - const cliHost = await getCLIHost(process.cwd(), loadNativeModule, false); - const configFile = URI.file(path.resolve('./src/test/configs/extends/.devcontainer.invalid-merge.json')); - try { - await readDevContainerConfigFile(cliHost, workspace, configFile, false, false, nullLog); - assert.fail('expected invalid extendsMergeMode to throw'); - } catch (err: any) { - assert.match(String(err.description || err.message), /extendsMergeMode.*combine.*override/); - } + await expectReadConfigError('./src/test/configs/extends/.devcontainer.invalid-merge.json', /extendsMergeMode.*combine.*override/); }); 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'); - }); - - it('uses override merge when extendsMergeMode is override', function () { - const base: DevContainerConfig = { - image: 'mcr.microsoft.com/devcontainers/base:latest', - init: true, - privileged: true, - forwardPorts: [80], - hostRequirements: { - cpus: 4, - memory: '8gb', - storage: '32gb', - }, - }; - const overlay: DevContainerConfig = { - image: 'mcr.microsoft.com/devcontainers/javascript-node: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.deepEqual(merged.forwardPorts, [443]); - assert.deepEqual(merged.hostRequirements, { memory: '4gb' }); + await expectReadConfigError('./src/test/configs/extends/.devcontainer.missing.json', /was not found/); }); }); diff --git a/src/test/configs/extends-up-override/.devcontainer.json b/src/test/configs/extends-up-override/.devcontainer.json deleted file mode 100644 index 53041d0bd..000000000 --- a/src/test/configs/extends-up-override/.devcontainer.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "../extends-up/.devcontainer.base.json", - "extendsMergeMode": "override", - "name": "extends-up-override", - "forwardPorts": [9999], - "remoteEnv": { - "OVERRIDE_ME": "child", - "EXTENDS_UP": "override" - }, - "init": false -} diff --git a/src/test/configs/extends-up/.devcontainer.base.json b/src/test/configs/extends-up/.devcontainer.base.json deleted file mode 100644 index b1de7ec48..000000000 --- a/src/test/configs/extends-up/.devcontainer.base.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "image": "ubuntu:latest", - "remoteEnv": { - "FROM_BASE": "base", - "OVERRIDE_ME": "base" - }, - "runArgs": ["-e", "EXTENDS_UP_BASE=1"] -} diff --git a/src/test/configs/extends-up/.devcontainer.json b/src/test/configs/extends-up/.devcontainer.json deleted file mode 100644 index 8c9dd3078..000000000 --- a/src/test/configs/extends-up/.devcontainer.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "./.devcontainer.base.json", - "name": "extends-up-combine", - "remoteEnv": { - "OVERRIDE_ME": "child", - "EXTENDS_UP": "combine" - } -} diff --git a/src/test/configs/extends/.devcontainer.base.json b/src/test/configs/extends/.devcontainer.base.json index 7f799fa84..c1c74f20c 100644 --- a/src/test/configs/extends/.devcontainer.base.json +++ b/src/test/configs/extends/.devcontainer.base.json @@ -1,6 +1,5 @@ { - "name": "example configuration", - "image": "mcr.microsoft.com/devcontainers/base:latest", + "image": "ubuntu:latest", "forwardPorts": [80], "capAdd": ["SYS_PTRACE"], "hostRequirements": { @@ -10,10 +9,5 @@ "remoteEnv": { "FROM_BASE": "base", "OVERRIDE_ME": "base" - }, - "features": { - "ghcr.io/devcontainers/features/go:1": { - "version": "latest" - } } } diff --git a/src/test/configs/extends/.devcontainer.json b/src/test/configs/extends/.devcontainer.json index 58cbc5815..b7a3149b3 100644 --- a/src/test/configs/extends/.devcontainer.json +++ b/src/test/configs/extends/.devcontainer.json @@ -8,11 +8,5 @@ }, "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.override.json b/src/test/configs/extends/.devcontainer.override.json index fa7520d98..2db3baf7c 100644 --- a/src/test/configs/extends/.devcontainer.override.json +++ b/src/test/configs/extends/.devcontainer.override.json @@ -9,11 +9,5 @@ "remoteEnv": { "OVERRIDE_ME": "child" }, - "init": false, - "features": { - "ghcr.io/devcontainers/features/docker-in-docker:1": { - "version": "latest", - "moby": true - } - } + "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 => ({ diff --git a/src/test/testUtils.ts b/src/test/testUtils.ts index 9e1c2a723..1eaaded55 100644 --- a/src/test/testUtils.ts +++ b/src/test/testUtils.ts @@ -89,15 +89,14 @@ export async function shellPtyExec(command: string, options: { stdin?: string } }).then(res => ({ code: 0, ...res }), error => error); } -export async function devContainerUp(cli: string, workspaceFolder: string, options?: { cwd?: string; useBuildKit?: boolean; userDataFolder?: string; logLevel?: string; config?: string; extraArgs?: string; prefix?: string; env?: NodeJS.ProcessEnv }): Promise { +export async function devContainerUp(cli: string, workspaceFolder: string, options?: { cwd?: string; useBuildKit?: boolean; userDataFolder?: string; logLevel?: string; extraArgs?: string; prefix?: string; env?: NodeJS.ProcessEnv }): Promise { const buildkitOption = (options?.useBuildKit ?? false) ? '' : ' --buildkit=never'; const userDataFolderOption = (options?.userDataFolder ?? false) ? ` --user-data-folder=${options?.userDataFolder}` : ''; const logLevelOption = (options?.logLevel ?? false) ? ` --log-level ${options?.logLevel}` : ''; - const configOption = (options?.config ?? false) ? ` --config ${options.config}` : ''; const extraArgs = (options?.extraArgs ?? false) ? ` ${options?.extraArgs}` : ''; const prefix = (options?.prefix ?? false) ? `${options?.prefix} ` : ''; const shellExecOptions = { cwd: options?.cwd, env: options?.env }; - const res = await shellExec(`${prefix}${cli} up --workspace-folder ${workspaceFolder}${configOption}${buildkitOption}${userDataFolderOption}${extraArgs} ${logLevelOption}`, shellExecOptions); + const res = await shellExec(`${prefix}${cli} up --workspace-folder ${workspaceFolder}${buildkitOption}${userDataFolderOption}${extraArgs} ${logLevelOption}`, shellExecOptions); const response = JSON.parse(res.stdout); assert.equal(response.outcome, 'success'); const { outcome, containerId, composeProjectName } = response as UpResult;