From e651585b5e6f164adb4afbb883f65db02215a510 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Mon, 24 Oct 2022 16:44:30 +0200 Subject: [PATCH] ${devcontainerId} (devcontainers/spec#62) --- src/spec-common/variableSubstitution.ts | 30 ++++++++++++++++++++++++- src/spec-node/configContainer.ts | 6 ++--- src/spec-node/devContainersSpecCLI.ts | 29 ++++++------------------ src/spec-node/utils.ts | 13 +++++++++-- src/test/variableSubstitution.test.ts | 28 ++++++++++++++++++++++- 5 files changed, 77 insertions(+), 29 deletions(-) diff --git a/src/spec-common/variableSubstitution.ts b/src/spec-common/variableSubstitution.ts index f7d2c67ad..c4e2a17bf 100644 --- a/src/spec-common/variableSubstitution.ts +++ b/src/spec-common/variableSubstitution.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as path from 'path'; +import * as crypto from 'crypto'; import { ContainerError } from './errors'; import { URI } from 'vscode-uri'; @@ -32,6 +33,11 @@ export function substitute(context: SubstitutionContext, value return substitute0(replace, value); } +export function beforeContainerSubstitute(idLabels: Record, value: T): T { + let devcontainerId: string | undefined; + return substitute0(replaceDevContainerId.bind(undefined, () => devcontainerId || (devcontainerId = devcontainerIdForLabels(idLabels))), value); +} + export function containerSubstitute(platform: NodeJS.Platform, configFile: URI | undefined, containerEnv: NodeJS.ProcessEnv, value: T): T { const isWindows = platform === 'win32'; return substitute0(replaceContainerEnv.bind(undefined, isWindows, configFile, normalizeEnv(isWindows, containerEnv)), value); @@ -44,7 +50,7 @@ function substitute0(replace: Replace, value: any): any { return resolveString(replace, value); } else if (Array.isArray(value)) { return value.map(s => substitute0(replace, s)); - } else if (value && typeof value === 'object') { + } else if (value && typeof value === 'object' && !URI.isUri(value)) { const result: any = Object.create(null); Object.keys(value).forEach(key => { result[key] = substitute0(replace, value[key]); @@ -118,6 +124,16 @@ function replaceContainerEnv(isWindows: boolean, configFile: URI | undefined, co } } +function replaceDevContainerId(getDevContainerId: () => string, match: string, variable: string) { + switch (variable) { + case 'devcontainerId': + return getDevContainerId(); + + default: + return match; + } +} + function lookupValue(isWindows: boolean, envObj: NodeJS.ProcessEnv, args: string[], match: string, configFile: URI | undefined) { if (args.length > 0) { let envVariableName = args[0]; @@ -141,3 +157,15 @@ function lookupValue(isWindows: boolean, envObj: NodeJS.ProcessEnv, args: string description: `'${match}'${configFile ? ` in ${path.posix.basename(configFile.path)}` : ''} can not be resolved because no environment variable name is given.` }); } + +function devcontainerIdForLabels(idLabels: Record): string { + const stringInput = JSON.stringify(idLabels, Object.keys(idLabels).sort()); // sort properties + const bufferInput = Buffer.from(stringInput, 'utf-8'); + const hash = crypto.createHash('sha256') + .update(bufferInput) + .digest(); + const uniqueId = BigInt(`0x${hash.toString('hex')}`) + .toString(32) + .padStart(52, '0'); + return uniqueId; +} diff --git a/src/spec-node/configContainer.ts b/src/spec-node/configContainer.ts index 543e0df82..facdafbb6 100644 --- a/src/spec-node/configContainer.ts +++ b/src/spec-node/configContainer.ts @@ -9,8 +9,8 @@ import * as jsonc from 'jsonc-parser'; import { openDockerfileDevContainer } from './singleContainer'; import { openDockerComposeDevContainer } from './dockerCompose'; -import { ResolverResult, DockerResolverParameters, isDockerFileConfig, runUserCommand, createDocuments, getWorkspaceConfiguration, BindMountConsistency, uriToFsPath, DevContainerAuthority, isDevContainerAuthority, SubstituteConfig, SubstitutedConfig } from './utils'; -import { substitute } from '../spec-common/variableSubstitution'; +import { ResolverResult, DockerResolverParameters, isDockerFileConfig, runUserCommand, createDocuments, getWorkspaceConfiguration, BindMountConsistency, uriToFsPath, DevContainerAuthority, isDevContainerAuthority, SubstituteConfig, SubstitutedConfig, addSubstitution, envListToObj } from './utils'; +import { beforeContainerSubstitute, substitute } from '../spec-common/variableSubstitution'; import { ContainerError } from '../spec-common/errors'; import { Workspace, workspaceFromPath, isWorkspacePath } from '../spec-utils/workspaces'; import { URI } from 'vscode-uri'; @@ -52,7 +52,7 @@ async function resolveWithLocalFolder(params: DockerResolverParameters, parsedAu throw new ContainerError({ description: `No dev container config and no workspace found.` }); } } - const configWithRaw = configs.config; + const configWithRaw = addSubstitution(configs.config, config => beforeContainerSubstitute(envListToObj(idLabels), config)); const { config } = configWithRaw; await runUserCommand({ ...params, common: { ...common, output: common.postCreate.output } }, config.initializeCommand, common.postCreate.onDidInput); diff --git a/src/spec-node/devContainersSpecCLI.ts b/src/spec-node/devContainersSpecCLI.ts index 83a5f0cce..38a5d85f5 100644 --- a/src/spec-node/devContainersSpecCLI.ts +++ b/src/spec-node/devContainersSpecCLI.ts @@ -9,7 +9,7 @@ import yargs, { Argv } from 'yargs'; import * as jsonc from 'jsonc-parser'; import { createDockerParams, createLog, experimentalImageMetadataDefault, launch, ProvisionOptions } from './devContainers'; -import { SubstitutedConfig, createContainerProperties, createFeaturesTempFolder, envListToObj, inspectDockerImage, isDockerFileConfig, SubstituteConfig } from './utils'; +import { SubstitutedConfig, createContainerProperties, createFeaturesTempFolder, envListToObj, inspectDockerImage, isDockerFileConfig, SubstituteConfig, addSubstitution } from './utils'; import { URI } from 'vscode-uri'; import { ContainerError } from '../spec-common/errors'; import { Log, LogLevel, makeLog, mapLogLevel } from '../spec-utils/log'; @@ -30,7 +30,7 @@ import { featuresTestOptions, featuresTestHandler } from './featuresCLI/test'; import { featuresPackageHandler, featuresPackageOptions } from './featuresCLI/package'; import { featuresPublishHandler, featuresPublishOptions } from './featuresCLI/publish'; import { featuresInfoHandler, featuresInfoOptions } from './featuresCLI/info'; -import { containerSubstitute } from '../spec-common/variableSubstitution'; +import { beforeContainerSubstitute, containerSubstitute } from '../spec-common/variableSubstitution'; import { getPackageConfig, PackageConfiguration } from '../spec-utils/product'; import { getDevcontainerMetadata, getImageBuildInfo, getImageMetadataFromContainer, ImageMetadataEntry, mergeConfiguration, MergedDevContainerConfig } from './imageMetadata'; import { templatesPublishHandler, templatesPublishOptions } from './templatesCLI/publish'; @@ -214,7 +214,7 @@ async function provision({ }; }) : [], updateRemoteUserUIDDefault, - remoteEnv: keyValuesToRecord(addRemoteEnvs), + remoteEnv: envListToObj(addRemoteEnvs), additionalCacheFroms: addCacheFroms, useBuildKit: buildkit, buildxPlatform: undefined, @@ -597,7 +597,7 @@ async function doRunUserCommands({ persistedFolder, additionalMounts: [], updateRemoteUserUIDDefault: 'never', - remoteEnv: keyValuesToRecord(addRemoteEnvs), + remoteEnv: envListToObj(addRemoteEnvs), additionalCacheFroms: [], useBuildKit: 'auto', buildxPlatform: undefined, @@ -759,13 +759,8 @@ async function readConfiguration({ }; const container = containerId ? await inspectContainer(params, containerId) : await findDevContainer(params, idLabels); if (container) { - const substitute1 = configuration.substitute; - const substitute2: SubstituteConfig = config => containerSubstitute(cliHost.platform, configuration.config.configFilePath, envListToObj(container.Config.Env), config); - configuration = { - config: substitute2(configuration.config), - raw: configuration.raw, - substitute: config => substitute2(substitute1(config)), - }; + configuration = addSubstitution(configuration, config => beforeContainerSubstitute(envListToObj(idLabels), config)); + configuration = addSubstitution(configuration, config => containerSubstitute(cliHost.platform, configuration.config.configFilePath, envListToObj(container.Config.Env), config)); } const additionalFeatures = additionalFeaturesJson ? jsonc.parse(additionalFeaturesJson) as Record> : {}; @@ -927,7 +922,7 @@ export async function doExec({ persistedFolder, additionalMounts: [], updateRemoteUserUIDDefault: 'never', - remoteEnv: keyValuesToRecord(addRemoteEnvs), + remoteEnv: envListToObj(addRemoteEnvs), additionalCacheFroms: [], useBuildKit: 'auto', omitLoggerHeader: true, @@ -989,16 +984,6 @@ export async function doExec({ } } -function keyValuesToRecord(keyValues: string[]): Record { - return keyValues.reduce((envs, env) => { - const i = env.indexOf('='); - if (i !== -1) { - envs[env.substring(0, i)] = env.substring(i + 1); - } - return envs; - }, {} as Record); -} - function getDefaultIdLabels(workspaceFolder: string) { return [`${hostFolderLabel}=${workspaceFolder}`]; } diff --git a/src/spec-node/utils.ts b/src/spec-node/utils.ts index fa0b1da62..d75108310 100644 --- a/src/spec-node/utils.ts +++ b/src/spec-node/utils.ts @@ -120,6 +120,15 @@ export interface SubstitutedConfig { export type SubstituteConfig = (value: U) => U; +export function addSubstitution(config: SubstitutedConfig, substitute: SubstituteConfig): SubstitutedConfig { + const substitute0 = config.substitute; + return { + config: substitute(config.config), + raw: config.raw, + substitute: value => substitute(substitute0(value)), + }; +} + export async function startEventSeen(params: DockerResolverParameters, labels: Record, canceled: Promise, output: Log, trace: boolean) { const eventsProcess = await getEvents(params, { event: ['start'] }); return { @@ -350,10 +359,10 @@ export function envListToObj(list: string[] | null) { return (list || []).reduce((obj, pair) => { const i = pair.indexOf('='); if (i !== -1) { - obj[pair.substr(0, i)] = pair.substr(i + 1); + obj[pair.substring(0, i)] = pair.substring(i + 1); } return obj; - }, {} as NodeJS.ProcessEnv); + }, {} as Record); } export async function runUserCommand(params: DockerResolverParameters, command: string | string[] | undefined, onDidInput?: Event) { diff --git a/src/test/variableSubstitution.test.ts b/src/test/variableSubstitution.test.ts index 39a225107..e95002464 100644 --- a/src/test/variableSubstitution.test.ts +++ b/src/test/variableSubstitution.test.ts @@ -4,7 +4,7 @@ import * as assert from 'assert'; -import { containerSubstitute, substitute } from '../spec-common/variableSubstitution'; +import { beforeContainerSubstitute, containerSubstitute, substitute } from '../spec-common/variableSubstitution'; import { URI } from 'vscode-uri'; describe('Variable substitution', function () { @@ -141,4 +141,30 @@ describe('Variable substitution', function () { const result = containerSubstitute('linux', URI.file('/foo/bar/baz.json'), {}, raw); assert.strictEqual(result.foo, 'bardefaultbar'); }); + + it(`replaces devcontainerId`, async () => { + const raw = { + test: '${devcontainerId}' + }; + const result = beforeContainerSubstitute({ a: 'b' }, raw); + assert.ok(/^[0-9a-v]{52}$/.test(result.test), `Got: ${result.test}`); + }); + + it(`replaces devcontainerId and additional id labels matter`, async () => { + const raw = { + test: '${devcontainerId}' + }; + const result1 = beforeContainerSubstitute({ a: 'b' }, raw); + const result2 = beforeContainerSubstitute({ a: 'b', c: 'd' }, raw); + assert.notStrictEqual(result1.test, result2.test); + }); + + it(`replaces devcontainerId and label order does not matter`, async () => { + const raw = { + test: '${devcontainerId}' + }; + const result1 = beforeContainerSubstitute({ c: 'd', a: 'b' }, raw); + const result2 = beforeContainerSubstitute({ a: 'b', c: 'd' }, raw); + assert.strictEqual(result1.test, result2.test); + }); });