diff --git a/package.json b/package.json index c9549c82d..6072b85c3 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ }, "dependencies": { "chalk": "^4", + "deepmerge-ts": "^4.2.2", "follow-redirects": "^1.14.8", "js-yaml": "^4.1.0", "jsonc-parser": "^3.0.0", diff --git a/src/spec-configuration/configuration.ts b/src/spec-configuration/configuration.ts index 6004894dc..1023b08d0 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; } & ( { @@ -166,6 +168,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 facdafbb6..bd2958858 100644 --- a/src/spec-node/configContainer.ts +++ b/src/spec-node/configContainer.ts @@ -3,9 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as path from 'path'; - +import * as deepmerge from 'deepmerge-ts'; import * as jsonc from 'jsonc-parser'; +import * as path from 'path'; import { openDockerfileDevContainer } from './singleContainer'; import { openDockerComposeDevContainer } from './dockerCompose'; @@ -71,18 +71,33 @@ async function resolveWithLocalFolder(params: DockerResolverParameters, parsedAu return result; } -export async function readDevContainerConfigFile(cliHost: CLIHost, workspace: Workspace | undefined, configFile: URI, mountWorkspaceGitRoot: boolean, output: Log, consistency?: BindMountConsistency, overrideConfigFile?: URI) { +async function readFile(cliHost: CLIHost, path: URI) { const documents = createDocuments(cliHost); - const content = await documents.readDocument(overrideConfigFile ?? configFile); + const content = await documents.readDocument(path); 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(path, cliHost.platform)}) must contain a JSON object literal.` }); } - const workspaceConfig = await getWorkspaceConfiguration(cliHost, workspace, updated, mountWorkspaceGitRoot, output, consistency); + + return updated; +} + +export async function readDevContainerConfigFile(cliHost: CLIHost, workspace: Workspace | undefined, configFile: URI, mountWorkspaceGitRoot: boolean, output: Log, consistency?: BindMountConsistency, overrideConfigFile?: URI) { + const confPath = overrideConfigFile ?? configFile; + let content = await readFile(cliHost, confPath) as DevContainerConfig; + + if (content.extends) { + const extendsConfPath = path.resolve(path.dirname(confPath.path), content.extends); + const referencedContent = await readFile(cliHost, URI.file(extendsConfPath)) as DevContainerConfig; + delete content.extends; + content = deepmerge.deepmerge(referencedContent, content); + } + + const workspaceConfig = await getWorkspaceConfiguration(cliHost, workspace, content, mountWorkspaceGitRoot, output, consistency); const substitute0: SubstituteConfig = value => substitute({ platform: cliHost.platform, localWorkspaceFolder: workspace?.rootFolderPath, @@ -90,7 +105,7 @@ export async function readDevContainerConfigFile(cliHost: CLIHost, workspace: Wo configFile, env: cliHost.env, }, value); - const config: DevContainerConfig = substitute0(updated); + const config: DevContainerConfig = substitute0(content); if (typeof config.workspaceFolder === 'string') { workspaceConfig.workspaceFolder = config.workspaceFolder; } @@ -101,7 +116,7 @@ export async function readDevContainerConfigFile(cliHost: CLIHost, workspace: Wo return { config: { config, - raw: updated, + raw: content, substitute: substitute0, }, workspaceConfig, diff --git a/src/test/configs/extends/.devcontainer.base.json b/src/test/configs/extends/.devcontainer.base.json new file mode 100644 index 000000000..54b730783 --- /dev/null +++ b/src/test/configs/extends/.devcontainer.base.json @@ -0,0 +1,10 @@ +{ + "name": "example configuration", + "image": "mcr.microsoft.com/devcontainers/base:latest", + "forwardPorts": [80], + "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 new file mode 100644 index 000000000..6ce69e4cd --- /dev/null +++ b/src/test/configs/extends/.devcontainer.json @@ -0,0 +1,11 @@ +{ + "extends": "./.devcontainer.base.json", + "name": "Overrides", + "forwardPorts": [443], + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:1": { + "version": "latest", + "moby": true + } + } +} diff --git a/src/test/spec-node/configContainers.test.ts b/src/test/spec-node/configContainers.test.ts new file mode 100644 index 000000000..f4bb2dd68 --- /dev/null +++ b/src/test/spec-node/configContainers.test.ts @@ -0,0 +1,63 @@ +import path from 'path'; +import { getCLIHost } from '../../spec-common/cliHost'; +import { loadNativeModule } from '../../spec-common/commonUtils'; +import { readDevContainerConfigFile } from '../../spec-node/configContainer'; +import { nullLog } from '../../spec-utils/log'; +import { URI } from 'vscode-uri'; +import { assert } from 'chai'; + +describe('readDevContainerConfigFile', async function () { + it('can read a basic configuration file', async function () { + const cwd = process.cwd(); + const cliHost = await getCLIHost(cwd, loadNativeModule); + const workspace = { + isWorkspaceFile: true, + workspaceOrFolderPath: '/foo/bar', + rootFolderPath: '/foo/bar', + configFolderPath: '/foo/bar', + }; + const configFile = URI.file(path.resolve('./src/test/configs/example/.devcontainer.json')); + + const configs = await readDevContainerConfigFile(cliHost, workspace, configFile, false, nullLog); + assert.isNotNull(configs); + assert.property(configs, 'config'); + assert.isNotNull(configs?.config.config); + + const features = configs?.config.config.features as Record>; + assert.hasAllKeys(features, ['ghcr.io/devcontainers/features/go:1']); + }); + + it('can resolve an "extends" file reference', async function () { + const cwd = process.cwd(); + const cliHost = await getCLIHost(cwd, loadNativeModule); + const workspace = { + isWorkspaceFile: true, + workspaceOrFolderPath: '/foo/bar', + rootFolderPath: '/foo/bar', + configFolderPath: '/foo/bar', + }; + const configFile = URI.file(path.resolve('./src/test/configs/extends/.devcontainer.json')); + + const configs = await readDevContainerConfigFile(cliHost, workspace, configFile, false, nullLog); + assert.isNotNull(configs); + assert.property(configs, 'config'); + assert.isNotNull(configs?.config.config); + assert.isNotNull(configs?.config.raw); + const expectedConfig = { + name: 'Overrides', + image: 'mcr.microsoft.com/devcontainers/base:latest', + forwardPorts: [ 80, 443 ], + 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); + }); +}); diff --git a/yarn.lock b/yarn.lock index 5ab56655f..acfc37cc9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -815,6 +815,11 @@ deep-is@^0.1.3, deep-is@~0.1.3: resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ= +deepmerge-ts@^4.2.2: + version "4.2.2" + resolved "https://registry.yarnpkg.com/deepmerge-ts/-/deepmerge-ts-4.2.2.tgz#582bf34a37592dc8274b137617b539f871aaf11a" + integrity sha512-Ka3Kb21tiWjvQvS9U+1Dx+aqFAHsdTnMdYptLTmC2VAmDFMugWMY1e15aTODstipmCun8iNuqeSfcx6rsUUk0Q== + define-properties@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1"