diff --git a/src/spec-common/variableSubstitution.ts b/src/spec-common/variableSubstitution.ts index c4e2a17bf..531f47546 100644 --- a/src/spec-common/variableSubstitution.ts +++ b/src/spec-common/variableSubstitution.ts @@ -11,9 +11,9 @@ import { URI } from 'vscode-uri'; export interface SubstitutionContext { platform: NodeJS.Platform; - configFile: URI; - localWorkspaceFolder: string | undefined; - containerWorkspaceFolder: string | undefined; + configFile?: URI; + localWorkspaceFolder?: string; + containerWorkspaceFolder?: string; env: NodeJS.ProcessEnv; } @@ -33,9 +33,9 @@ export function substitute(context: SubstitutionContext, value return substitute0(replace, value); } -export function beforeContainerSubstitute(idLabels: Record, value: T): T { +export function beforeContainerSubstitute(idLabels: Record | undefined, value: T): T { let devcontainerId: string | undefined; - return substitute0(replaceDevContainerId.bind(undefined, () => devcontainerId || (devcontainerId = devcontainerIdForLabels(idLabels))), value); + return substitute0(replaceDevContainerId.bind(undefined, () => devcontainerId || (idLabels && (devcontainerId = devcontainerIdForLabels(idLabels)))), value); } export function containerSubstitute(platform: NodeJS.Platform, configFile: URI | undefined, containerEnv: NodeJS.ProcessEnv, value: T): T { @@ -124,10 +124,10 @@ function replaceContainerEnv(isWindows: boolean, configFile: URI | undefined, co } } -function replaceDevContainerId(getDevContainerId: () => string, match: string, variable: string) { +function replaceDevContainerId(getDevContainerId: () => string | undefined, match: string, variable: string) { switch (variable) { case 'devcontainerId': - return getDevContainerId(); + return getDevContainerId() || match; default: return match; diff --git a/src/spec-configuration/configuration.ts b/src/spec-configuration/configuration.ts index 6004894dc..039eec8fe 100644 --- a/src/spec-configuration/configuration.ts +++ b/src/spec-configuration/configuration.ts @@ -39,8 +39,8 @@ export interface DevContainerFeature { } export interface DevContainerFromImageConfig { - configFilePath: URI; - image: string; + configFilePath?: URI; + image?: string; // Only optional when setting up an existing container as a dev container. name?: string; forwardPorts?: (number | string)[]; appPort?: number | string | (number | string)[]; diff --git a/src/spec-configuration/containerFeaturesConfiguration.ts b/src/spec-configuration/containerFeaturesConfiguration.ts index 2b3355740..60dc4a853 100644 --- a/src/spec-configuration/containerFeaturesConfiguration.ts +++ b/src/spec-configuration/containerFeaturesConfiguration.ts @@ -575,7 +575,7 @@ function featuresToArray(config: DevContainerConfig, additionalFeatures: Record< async function processUserFeatures(params: ContainerFeatureInternalParams, config: DevContainerConfig, workspaceRoot: string, userFeatures: DevContainerFeature[], featuresConfig: FeaturesConfig): Promise { const { platform, output } = params; - let configPath = uriToFsPath(config.configFilePath, platform); + let configPath = config.configFilePath && uriToFsPath(config.configFilePath, platform); output.write(`configPath: ${configPath}`, LogLevel.Trace); for (const userFeature of userFeatures) { @@ -673,7 +673,7 @@ export function getBackwardCompatibleFeatureId(output: Log, id: string) { // Strictly processes the user provided feature identifier to determine sourceInformation type. // Returns a featureSet per feature. -export async function processFeatureIdentifier(params: CommonParams, configPath: string, _workspaceRoot: string, userFeature: DevContainerFeature, skipFeatureAutoMapping?: boolean): Promise { +export async function processFeatureIdentifier(params: CommonParams, configPath: string | undefined, _workspaceRoot: string, userFeature: DevContainerFeature, skipFeatureAutoMapping?: boolean): Promise { const { output } = params; output.write(`* Processing feature: ${userFeature.id}`); @@ -765,6 +765,10 @@ export async function processFeatureIdentifier(params: CommonParams, configPath: } // Local-path features are expected to be a sub-folder of the '$WORKSPACE_ROOT/.devcontainer' folder. + if (!configPath) { + output.write('A local feature requires a configuration path.', LogLevel.Error); + return undefined; + } const featureFolderPath = path.join(path.dirname(configPath), userFeature.id); // Ensure we aren't escaping .devcontainer folder diff --git a/src/spec-node/devContainers.ts b/src/spec-node/devContainers.ts index 6fcb3a83f..1891f16db 100644 --- a/src/spec-node/devContainers.ts +++ b/src/spec-node/devContainers.ts @@ -29,7 +29,6 @@ export interface ProvisionOptions { workspaceFolder: string | undefined; workspaceMountConsistency?: BindMountConsistency; mountWorkspaceGitRoot: boolean; - idLabels: string[]; configFile: URI | undefined; overrideConfigFile: URI | undefined; logLevel: LogLevel; @@ -66,13 +65,13 @@ export interface ProvisionOptions { }; } -export async function launch(options: ProvisionOptions, disposables: (() => Promise | undefined)[]) { +export async function launch(options: ProvisionOptions, idLabels: string[], disposables: (() => Promise | undefined)[]) { const params = await createDockerParams(options, disposables); const output = params.common.output; const text = 'Resolving Remote'; const start = output.start(text); - const result = await resolve(params, options.configFile, options.overrideConfigFile, options.idLabels, options.additionalFeatures ?? {}); + const result = await resolve(params, options.configFile, options.overrideConfigFile, idLabels, options.additionalFeatures ?? {}); output.stop(text, start); const { dockerContainerId, composeProjectName } = result; return { diff --git a/src/spec-node/devContainersSpecCLI.ts b/src/spec-node/devContainersSpecCLI.ts index b13310e8b..d951fc7ba 100644 --- a/src/spec-node/devContainersSpecCLI.ts +++ b/src/spec-node/devContainersSpecCLI.ts @@ -13,8 +13,7 @@ import { SubstitutedConfig, createContainerProperties, createFeaturesTempFolder, import { URI } from 'vscode-uri'; import { ContainerError } from '../spec-common/errors'; import { Log, LogLevel, makeLog, mapLogLevel } from '../spec-utils/log'; -import { UnpackPromise } from '../spec-utils/types'; -import { probeRemoteEnv, runPostCreateCommands, runRemoteCommand, UserEnvProbe } from '../spec-common/injectHeadless'; +import { probeRemoteEnv, runPostCreateCommands, runRemoteCommand, UserEnvProbe, setupInContainer } from '../spec-common/injectHeadless'; import { bailOut, buildNamedImageAndExtend, findDevContainer, hostFolderLabel } from './singleContainer'; import { extendImage } from './containerFeatures'; import { DockerCLIParameters, dockerPtyCLI, inspectContainer } from '../spec-shutdown/dockerUtils'; @@ -30,7 +29,7 @@ import { featuresTestOptions, featuresTestHandler } from './featuresCLI/test'; import { featuresPackageHandler, featuresPackageOptions } from './featuresCLI/package'; import { featuresPublishHandler, featuresPublishOptions } from './featuresCLI/publish'; import { featureInfoTagsHandler, featuresInfoTagsOptions } from './featuresCLI/infoTags'; -import { beforeContainerSubstitute, containerSubstitute } from '../spec-common/variableSubstitution'; +import { beforeContainerSubstitute, containerSubstitute, substitute } 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'; @@ -60,6 +59,7 @@ const mountRegex = /^type=(bind|volume),source=([^,]+),target=([^,]+)(?:,externa .strict(); y.wrap(Math.min(120, y.terminalWidth())); y.command('up', 'Create and run dev container', provisionOptions, provisionHandler); + y.command('set-up', 'Set up an existing container as a dev container', setUpOptions, setUpHandler); y.command('build [path]', 'Build a dev container image', buildOptions, buildHandler); y.command('run-user-commands', 'Run user commands', runUserCommandsOptions, runUserCommandsHandler); y.command('read-configuration', 'Read configuration', readConfigurationOptions, readConfigurationHandler); @@ -193,6 +193,7 @@ async function provision({ const addRemoteEnvs = addRemoteEnv ? (Array.isArray(addRemoteEnv) ? addRemoteEnv as string[] : [addRemoteEnv]) : []; const addCacheFroms = addCacheFrom ? (Array.isArray(addCacheFrom) ? addCacheFrom as string[] : [addCacheFrom]) : []; const additionalFeatures = additionalFeaturesJson ? jsonc.parse(additionalFeaturesJson) as Record> : {}; + const idLabels = idLabel ? (Array.isArray(idLabel) ? idLabel as string[] : [idLabel]) : getDefaultIdLabels(workspaceFolder!); const options: ProvisionOptions = { dockerPath, dockerComposePath, @@ -201,7 +202,6 @@ async function provision({ workspaceFolder, workspaceMountConsistency, mountWorkspaceGitRoot, - idLabels: idLabel ? (Array.isArray(idLabel) ? idLabel as string[] : [idLabel]) : getDefaultIdLabels(workspaceFolder!), configFile: config ? URI.file(path.resolve(process.cwd(), config)) : undefined, overrideConfigFile: overrideConfig ? URI.file(path.resolve(process.cwd(), overrideConfig)) : undefined, logLevel: mapLogLevel(logLevel), @@ -245,7 +245,7 @@ async function provision({ skipPersistingCustomizationsFromFeatures: false, }; - const result = await doProvision(options); + const result = await doProvision(options, idLabels); const exitCode = result.outcome === 'error' ? 1 : 0; console.log(JSON.stringify(result)); if (result.outcome === 'success') { @@ -255,13 +255,13 @@ async function provision({ process.exit(exitCode); } -async function doProvision(options: ProvisionOptions) { +async function doProvision(options: ProvisionOptions, idLabels: string[]) { const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { await Promise.all(disposables.map(d => d())); }; try { - const result = await launch(options, disposables); + const result = await launch(options, idLabels, disposables); return { outcome: 'success' as 'success', dispose, @@ -286,7 +286,165 @@ async function doProvision(options: ProvisionOptions) { } } -export type Result = UnpackPromise> & { backgroundProcessPID?: number }; +function setUpOptions(y: Argv) { + return y.options({ + 'docker-path': { type: 'string', description: 'Docker CLI path.' }, + 'container-data-folder': { type: 'string', description: 'Container data folder where user data inside the container will be stored.' }, + 'container-system-data-folder': { type: 'string', description: 'Container system data folder where system data inside the container will be stored.' }, + 'container-id': { type: 'string', required: true, description: 'Id of the container.' }, + 'config': { type: 'string', description: 'devcontainer.json path.' }, + 'log-level': { choices: ['info' as 'info', 'debug' as 'debug', 'trace' as 'trace'], default: 'info' as 'info', description: 'Log level for the --terminal-log-file. When set to trace, the log level for --log-file will also be set to trace.' }, + 'log-format': { choices: ['text' as 'text', 'json' as 'json'], default: 'text' as 'text', description: 'Log format.' }, + 'terminal-columns': { type: 'number', implies: ['terminal-rows'], description: 'Number of rows to render the output for. This is required for some of the subprocesses to correctly render their output.' }, + 'terminal-rows': { type: 'number', implies: ['terminal-columns'], description: 'Number of columns to render the output for. This is required for some of the subprocesses to correctly render their output.' }, + 'default-user-env-probe': { choices: ['none' as 'none', 'loginInteractiveShell' as 'loginInteractiveShell', 'interactiveShell' as 'interactiveShell', 'loginShell' as 'loginShell'], default: defaultDefaultUserEnvProbe, description: 'Default value for the devcontainer.json\'s "userEnvProbe".' }, + 'skip-post-create': { type: 'boolean', default: false, description: 'Do not run onCreateCommand, updateContentCommand, postCreateCommand, postStartCommand or postAttachCommand and do not install dotfiles.' }, + 'skip-non-blocking-commands': { type: 'boolean', default: false, description: 'Stop running user commands after running the command configured with waitFor or the updateContentCommand by default.' }, + 'user-data-folder': { type: 'string', description: 'Host path to a directory that is intended to be persisted and share state between sessions.' }, + 'remote-env': { type: 'string', description: 'Remote environment variables of the format name=value. These will be added when executing the user commands.' }, + 'dotfiles-repository': { type: 'string', description: 'URL of a dotfiles Git repository (e.g., https://github.com/owner/repository.git)' }, + 'dotfiles-install-command': { type: 'string', description: 'The command to run after cloning the dotfiles repository. Defaults to run the first file of `install.sh`, `install`, `bootstrap.sh`, `bootstrap`, `setup.sh` and `setup` found in the dotfiles repository`s root folder.' }, + 'dotfiles-target-path': { type: 'string', default: '~/dotfiles', description: 'The path to clone the dotfiles repository to. Defaults to `~/dotfiles`.' }, + 'container-session-data-folder': { type: 'string', description: 'Folder to cache CLI data, for example userEnvProb results' }, + }) + .check(argv => { + const remoteEnvs = (argv['remote-env'] && (Array.isArray(argv['remote-env']) ? argv['remote-env'] : [argv['remote-env']])) as string[] | undefined; + if (remoteEnvs?.some(remoteEnv => !/.+=.+/.test(remoteEnv))) { + throw new Error('Unmatched argument format: remote-env must match ='); + } + return true; + }); +} + +type SetUpArgs = UnpackArgv>; + +function setUpHandler(args: SetUpArgs) { + (async () => setUp(args))().catch(console.error); +} + +async function setUp(args: SetUpArgs) { + const result = await doSetUp(args); + const exitCode = result.outcome === 'error' ? 1 : 0; + console.log(JSON.stringify(result)); + await result.dispose(); + process.exit(exitCode); +} + +async function doSetUp({ + 'user-data-folder': persistedFolder, + 'docker-path': dockerPath, + 'container-data-folder': containerDataFolder, + 'container-system-data-folder': containerSystemDataFolder, + 'container-id': containerId, + config: configParam, + 'log-level': logLevel, + 'log-format': logFormat, + 'terminal-rows': terminalRows, + 'terminal-columns': terminalColumns, + 'default-user-env-probe': defaultUserEnvProbe, + 'skip-post-create': skipPostCreate, + 'skip-non-blocking-commands': skipNonBlocking, + 'remote-env': addRemoteEnv, + 'dotfiles-repository': dotfilesRepository, + 'dotfiles-install-command': dotfilesInstallCommand, + 'dotfiles-target-path': dotfilesTargetPath, + 'container-session-data-folder': containerSessionDataFolder, +}: SetUpArgs) { + + const disposables: (() => Promise | undefined)[] = []; + const dispose = async () => { + await Promise.all(disposables.map(d => d())); + }; + try { + const addRemoteEnvs = addRemoteEnv ? (Array.isArray(addRemoteEnv) ? addRemoteEnv as string[] : [addRemoteEnv]) : []; + const configFile = configParam ? URI.file(path.resolve(process.cwd(), configParam)) : undefined; + const params = await createDockerParams({ + dockerPath, + dockerComposePath: undefined, + containerSessionDataFolder, + containerDataFolder, + containerSystemDataFolder, + workspaceFolder: undefined, + mountWorkspaceGitRoot: false, + configFile, + overrideConfigFile: undefined, + logLevel: mapLogLevel(logLevel), + logFormat, + log: text => process.stderr.write(text), + terminalDimensions: terminalColumns && terminalRows ? { columns: terminalColumns, rows: terminalRows } : undefined, + defaultUserEnvProbe, + removeExistingContainer: false, + buildNoCache: false, + expectExistingContainer: false, + postCreateEnabled: !skipPostCreate, + skipNonBlocking, + prebuild: false, + persistedFolder, + additionalMounts: [], + updateRemoteUserUIDDefault: 'never', + remoteEnv: envListToObj(addRemoteEnvs), + additionalCacheFroms: [], + useBuildKit: 'auto', + buildxPlatform: undefined, + buildxPush: false, + buildxOutput: undefined, + skipFeatureAutoMapping: false, + skipPostAttach: false, + experimentalImageMetadata: true, + skipPersistingCustomizationsFromFeatures: false, + dotfiles: { + repository: dotfilesRepository, + installCommand: dotfilesInstallCommand, + targetPath: dotfilesTargetPath, + }, + }, disposables); + + const { common } = params; + const { cliHost, output } = common; + const configs = configFile && await readDevContainerConfigFile(cliHost, undefined, configFile, params.mountWorkspaceGitRoot, output, undefined, undefined); + if (configFile && !configs) { + throw new ContainerError({ description: `Dev container config (${uriToFsPath(configFile, cliHost.platform)}) not found.` }); + } + + const config0 = configs?.config || { + raw: {}, + config: {}, + substitute: value => substitute({ platform: cliHost.platform, env: cliHost.env }, value) + }; + + const container = await inspectContainer(params, containerId); + if (!container) { + bailOut(common.output, 'Dev container not found.'); + } + + const config1 = addSubstitution(config0, config => beforeContainerSubstitute(undefined, config)); + const config = addSubstitution(config1, config => containerSubstitute(cliHost.platform, config1.config.configFilePath, envListToObj(container.Config.Env), config)); + + const imageMetadata = getImageMetadataFromContainer(container, config, undefined, undefined, true, output).config; + const mergedConfig = mergeConfiguration(config.config, imageMetadata); + const containerProperties = await createContainerProperties(params, container.Id, configs?.workspaceConfig.workspaceFolder, mergedConfig.remoteUser); + await setupInContainer(common, containerProperties, mergedConfig); + return { + outcome: 'success' as 'success', + dispose, + }; + } catch (originalError) { + const originalStack = originalError?.stack; + const err = originalError instanceof ContainerError ? originalError : new ContainerError({ + description: 'An error occurred running user commands in the container.', + originalError + }); + if (originalStack) { + console.error(originalStack); + } + return { + outcome: 'error' as 'error', + message: err.message, + description: err.description, + dispose, + }; + } +} function buildOptions(y: Argv) { return y.options({ @@ -360,7 +518,6 @@ async function doBuild({ containerSystemDataFolder: undefined, workspaceFolder, mountWorkspaceGitRoot: false, - idLabels: getDefaultIdLabels(workspaceFolder), configFile, overrideConfigFile, logLevel: mapLogLevel(logLevel), @@ -469,6 +626,10 @@ async function doBuild({ } } else { + if (!config.image) { + throw new ContainerError({ description: 'No image information specified in devcontainer.json.' }); + } + await inspectDockerImage(params, config.image, true); const { updatedImageName } = await extendImage(params, configWithRaw, config.image, additionalFeatures, false); @@ -510,7 +671,7 @@ function runUserCommandsOptions(y: Argv) { 'docker-compose-path': { type: 'string', description: 'Docker Compose CLI path.' }, 'container-data-folder': { type: 'string', description: 'Container data folder where user data inside the container will be stored.' }, 'container-system-data-folder': { type: 'string', description: 'Container system data folder where system data inside the container will be stored.' }, - 'workspace-folder': { type: 'string', required: true, description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path.' }, + 'workspace-folder': { type: 'string', description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path.' }, 'mount-workspace-git-root': { type: 'boolean', default: true, description: 'Mount the workspace using its Git root.' }, 'container-id': { type: 'string', description: 'Id of the container to run the user commands for.' }, 'id-label': { type: 'string', description: 'Id label(s) of the format name=value. If no --container-id is given the id labels will be used to look up the container. If no --id-label is given, one will be inferred from the --workspace-folder path.' }, @@ -542,6 +703,9 @@ function runUserCommandsOptions(y: Argv) { if (remoteEnvs?.some(remoteEnv => !/.+=.+/.test(remoteEnv))) { throw new Error('Unmatched argument format: remote-env must match ='); } + if (!argv['container-id'] && !idLabels?.length && !argv['workspace-folder']) { + throw new Error('Missing required argument: One of --container-id, --id-label or --workspace-folder is required.'); + } return true; }); } @@ -593,8 +757,9 @@ async function doRunUserCommands({ await Promise.all(disposables.map(d => d())); }; try { - const workspaceFolder = path.resolve(process.cwd(), workspaceFolderArg); - const idLabels = idLabel ? (Array.isArray(idLabel) ? idLabel as string[] : [idLabel]) : getDefaultIdLabels(workspaceFolder); + const workspaceFolder = workspaceFolderArg ? path.resolve(process.cwd(), workspaceFolderArg) : undefined; + const idLabels = idLabel ? (Array.isArray(idLabel) ? idLabel as string[] : [idLabel]) : + workspaceFolder ? getDefaultIdLabels(workspaceFolder) : undefined; const addRemoteEnvs = addRemoteEnv ? (Array.isArray(addRemoteEnv) ? addRemoteEnv as string[] : [addRemoteEnv]) : []; const configFile = configParam ? URI.file(path.resolve(process.cwd(), configParam)) : undefined; const overrideConfigFile = overrideConfig ? URI.file(path.resolve(process.cwd(), overrideConfig)) : undefined; @@ -605,7 +770,6 @@ async function doRunUserCommands({ containerSystemDataFolder, workspaceFolder, mountWorkspaceGitRoot, - idLabels, configFile, overrideConfigFile, logLevel: mapLogLevel(logLevel), @@ -642,18 +806,23 @@ async function doRunUserCommands({ const { common } = params; const { cliHost, output } = common; - const workspace = workspaceFromPath(cliHost.path, workspaceFolder); + const workspace = workspaceFolder ? workspaceFromPath(cliHost.path, workspaceFolder) : undefined; const configPath = configFile ? configFile : workspace ? (await getDevContainerConfigPathIn(cliHost, workspace.configFolderPath) || (overrideConfigFile ? getDefaultDevContainerConfigPath(cliHost, workspace.configFolderPath) : undefined)) : overrideConfigFile; const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, params.mountWorkspaceGitRoot, output, undefined, overrideConfigFile) || undefined; - if (!configs) { + if (configPath && !configs) { throw new ContainerError({ description: `Dev container config (${uriToFsPath(configFile || getDefaultDevContainerConfigPath(cliHost, workspace!.configFolderPath), cliHost.platform)}) not found.` }); } - const { config: config0, workspaceConfig } = configs; - const container = containerId ? await inspectContainer(params, containerId) : await findDevContainer(params, idLabels); + const config0 = configs?.config || { + raw: {}, + config: {}, + substitute: value => substitute({ platform: cliHost.platform, env: cliHost.env }, value) + }; + + const container = containerId ? await inspectContainer(params, containerId) : await findDevContainer(params, idLabels!); if (!container) { bailOut(common.output, 'Dev container not found.'); } @@ -663,7 +832,7 @@ async function doRunUserCommands({ const imageMetadata = getImageMetadataFromContainer(container, config, undefined, idLabels, experimentalImageMetadata, output).config; const mergedConfig = mergeConfiguration(config.config, imageMetadata); - const containerProperties = await createContainerProperties(params, container.Id, workspaceConfig.workspaceFolder, mergedConfig.remoteUser); + const containerProperties = await createContainerProperties(params, container.Id, configs?.workspaceConfig.workspaceFolder, mergedConfig.remoteUser); const updatedConfig = containerSubstitute(cliHost.platform, config.config.configFilePath, containerProperties.env, mergedConfig); const remoteEnv = probeRemoteEnv(common, containerProperties, updatedConfig); const result = await runPostCreateCommands(common, containerProperties, updatedConfig, remoteEnv, stopForPersonalization); @@ -696,7 +865,7 @@ function readConfigurationOptions(y: Argv) { 'user-data-folder': { type: 'string', description: 'Host path to a directory that is intended to be persisted and share state between sessions.' }, 'docker-path': { type: 'string', description: 'Docker CLI path.' }, 'docker-compose-path': { type: 'string', description: 'Docker Compose CLI path.' }, - 'workspace-folder': { type: 'string', required: true, description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path.' }, + 'workspace-folder': { type: 'string', description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path.' }, 'mount-workspace-git-root': { type: 'boolean', default: true, description: 'Mount the workspace using its Git root.' }, 'container-id': { type: 'string', description: 'Id of the container to run the user commands for.' }, 'id-label': { type: 'string', description: 'Id label(s) of the format name=value. If no --container-id is given the id labels will be used to look up the container. If no --id-label is given, one will be inferred from the --workspace-folder path.' }, @@ -717,6 +886,9 @@ function readConfigurationOptions(y: Argv) { if (idLabels?.some(idLabel => !/.+=.+/.test(idLabel))) { throw new Error('Unmatched argument format: id-label must match ='); } + if (!argv['container-id'] && !idLabels?.length && !argv['workspace-folder']) { + throw new Error('Missing required argument: One of --container-id, --id-label or --workspace-folder is required.'); + } return true; }); } @@ -753,8 +925,9 @@ async function readConfiguration({ }; let output: Log | undefined; try { - const workspaceFolder = path.resolve(process.cwd(), workspaceFolderArg); - const idLabels = idLabel ? (Array.isArray(idLabel) ? idLabel as string[] : [idLabel]) : getDefaultIdLabels(workspaceFolder); + const workspaceFolder = workspaceFolderArg ? path.resolve(process.cwd(), workspaceFolderArg) : undefined; + const idLabels = idLabel ? (Array.isArray(idLabel) ? idLabel as string[] : [idLabel]) : + workspaceFolder ? getDefaultIdLabels(workspaceFolder) : undefined; const configFile = configParam ? URI.file(path.resolve(process.cwd(), configParam)) : undefined; const overrideConfigFile = overrideConfig ? URI.file(path.resolve(process.cwd(), overrideConfig)) : undefined; const cwd = workspaceFolder || process.cwd(); @@ -769,16 +942,21 @@ async function readConfiguration({ terminalDimensions: terminalColumns && terminalRows ? { columns: terminalColumns, rows: terminalRows } : undefined, }, pkg, sessionStart, disposables); - const workspace = workspaceFromPath(cliHost.path, workspaceFolder); + const workspace = workspaceFolder ? workspaceFromPath(cliHost.path, workspaceFolder) : undefined; const configPath = configFile ? configFile : workspace ? (await getDevContainerConfigPathIn(cliHost, workspace.configFolderPath) || (overrideConfigFile ? getDefaultDevContainerConfigPath(cliHost, workspace.configFolderPath) : undefined)) : overrideConfigFile; const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, mountWorkspaceGitRoot, output, undefined, overrideConfigFile) || undefined; - if (!configs) { + if (configPath && !configs) { throw new ContainerError({ description: `Dev container config (${uriToFsPath(configFile || getDefaultDevContainerConfigPath(cliHost, workspace!.configFolderPath), cliHost.platform)}) not found.` }); } - let configuration = configs.config; + + let configuration = configs?.config || { + raw: {}, + config: {}, + substitute: value => substitute({ platform: cliHost.platform, env: cliHost.env }, value) + }; const dockerCLI = dockerPath || 'docker'; const dockerComposeCLI = dockerComposeCLIConfig({ @@ -793,7 +971,7 @@ async function readConfiguration({ env: cliHost.env, output }; - const container = containerId ? await inspectContainer(params, containerId) : await findDevContainer(params, idLabels); + const container = containerId ? await inspectContainer(params, containerId) : await findDevContainer(params, idLabels!); if (container) { configuration = addSubstitution(configuration, config => beforeContainerSubstitute(envListToObj(idLabels), config)); configuration = addSubstitution(configuration, config => containerSubstitute(cliHost.platform, configuration.config.configFilePath, envListToObj(container.Config.Env), config)); @@ -810,15 +988,15 @@ async function readConfiguration({ const substitute2: SubstituteConfig = config => containerSubstitute(cliHost.platform, configuration.config.configFilePath, envListToObj(container.Config.Env), config); imageMetadata = imageMetadata.map(substitute2); } else { - const imageBuildInfo = await getImageBuildInfo(params, configs.config, experimentalImageMetadata); - imageMetadata = getDevcontainerMetadata(imageBuildInfo.metadata, configs.config, featuresConfiguration).config; + const imageBuildInfo = await getImageBuildInfo(params, configuration, experimentalImageMetadata); + imageMetadata = getDevcontainerMetadata(imageBuildInfo.metadata, configuration, featuresConfiguration).config; } mergedConfig = mergeConfiguration(configuration.config, imageMetadata); } await new Promise((resolve, reject) => { process.stdout.write(JSON.stringify({ configuration: configuration.config, - workspace: configs.workspaceConfig, + workspace: configs?.workspaceConfig, featuresConfiguration, mergedConfiguration: mergedConfig, }) + '\n', err => err ? reject(err) : resolve()); @@ -850,7 +1028,7 @@ function execOptions(y: Argv) { 'docker-compose-path': { type: 'string', description: 'Docker Compose CLI path.' }, 'container-data-folder': { type: 'string', description: 'Container data folder where user data inside the container will be stored.' }, 'container-system-data-folder': { type: 'string', description: 'Container system data folder where system data inside the container will be stored.' }, - 'workspace-folder': { type: 'string', required: true, description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path.' }, + 'workspace-folder': { type: 'string', description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path.' }, 'mount-workspace-git-root': { type: 'boolean', default: true, description: 'Mount the workspace using its Git root.' }, 'container-id': { type: 'string', description: 'Id of the container to run the user commands for.' }, 'id-label': { type: 'string', description: 'Id label(s) of the format name=value. If no --container-id is given the id labels will be used to look up the container. If no --id-label is given, one will be inferred from the --workspace-folder path.' }, @@ -884,6 +1062,9 @@ function execOptions(y: Argv) { if (remoteEnvs?.some(remoteEnv => !/.+=.+/.test(remoteEnv))) { throw new Error('Unmatched argument format: remote-env must match ='); } + if (!argv['container-id'] && !idLabels?.length && !argv['workspace-folder']) { + throw new Error('Missing required argument: One of --container-id, --id-label or --workspace-folder is required.'); + } return true; }); } @@ -929,8 +1110,9 @@ export async function doExec({ await Promise.all(disposables.map(d => d())); }; try { - const workspaceFolder = path.resolve(process.cwd(), workspaceFolderArg); - const idLabels = idLabel ? (Array.isArray(idLabel) ? idLabel as string[] : [idLabel]) : getDefaultIdLabels(workspaceFolder); + const workspaceFolder = workspaceFolderArg ? path.resolve(process.cwd(), workspaceFolderArg) : undefined; + const idLabels = idLabel ? (Array.isArray(idLabel) ? idLabel as string[] : [idLabel]) : + workspaceFolder ? getDefaultIdLabels(workspaceFolder) : undefined; const addRemoteEnvs = addRemoteEnv ? (Array.isArray(addRemoteEnv) ? addRemoteEnv as string[] : [addRemoteEnv]) : []; const configFile = configParam ? URI.file(path.resolve(process.cwd(), configParam)) : undefined; const overrideConfigFile = overrideConfig ? URI.file(path.resolve(process.cwd(), overrideConfig)) : undefined; @@ -941,7 +1123,6 @@ export async function doExec({ containerSystemDataFolder, workspaceFolder, mountWorkspaceGitRoot, - idLabels, configFile, overrideConfigFile, logLevel: mapLogLevel(logLevel), @@ -974,24 +1155,29 @@ export async function doExec({ const { common } = params; const { cliHost, output } = common; - const workspace = workspaceFromPath(cliHost.path, workspaceFolder); + const workspace = workspaceFolder ? workspaceFromPath(cliHost.path, workspaceFolder) : undefined; const configPath = configFile ? configFile : workspace ? (await getDevContainerConfigPathIn(cliHost, workspace.configFolderPath) || (overrideConfigFile ? getDefaultDevContainerConfigPath(cliHost, workspace.configFolderPath) : undefined)) : overrideConfigFile; const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, params.mountWorkspaceGitRoot, output, undefined, overrideConfigFile) || undefined; - if (!configs) { + if (configPath && !configs) { throw new ContainerError({ description: `Dev container config (${uriToFsPath(configFile || getDefaultDevContainerConfigPath(cliHost, workspace!.configFolderPath), cliHost.platform)}) not found.` }); } - const { config, workspaceConfig } = configs; - const container = containerId ? await inspectContainer(params, containerId) : await findDevContainer(params, idLabels); + const config = configs?.config || { + raw: {}, + config: {}, + substitute: value => substitute({ platform: cliHost.platform, env: cliHost.env }, value) + }; + + const container = containerId ? await inspectContainer(params, containerId) : await findDevContainer(params, idLabels!); if (!container) { bailOut(common.output, 'Dev container not found.'); } const imageMetadata = getImageMetadataFromContainer(container, config, undefined, idLabels, experimentalImageMetadata, output).config; const mergedConfig = mergeConfiguration(config.config, imageMetadata); - const containerProperties = await createContainerProperties(params, container.Id, workspaceConfig.workspaceFolder, mergedConfig.remoteUser); + const containerProperties = await createContainerProperties(params, container.Id, configs?.workspaceConfig.workspaceFolder, mergedConfig.remoteUser); const updatedConfig = containerSubstitute(cliHost.platform, config.config.configFilePath, containerProperties.env, mergedConfig); const remoteEnv = probeRemoteEnv(common, containerProperties, updatedConfig); const remoteCwd = containerProperties.remoteWorkspaceFolder || containerProperties.homeFolder; diff --git a/src/spec-node/featuresCLI/testCommandImpl.ts b/src/spec-node/featuresCLI/testCommandImpl.ts index 58e11a189..7d84f40f8 100644 --- a/src/spec-node/featuresCLI/testCommandImpl.ts +++ b/src/spec-node/featuresCLI/testCommandImpl.ts @@ -383,14 +383,12 @@ async function launchProject(params: DockerResolverParameters, args: FeaturesTes const { common } = params; let response = {} as LaunchResult; + const idLabels = [ `devcontainer.local_folder=${workspaceFolder}` ]; const options: ProvisionOptions = { ...staticProvisionParams, workspaceFolder, logLevel: common.getLogLevel(), mountWorkspaceGitRoot: true, - idLabels: [ - `devcontainer.local_folder=${workspaceFolder}` - ], remoteEnv: common.remoteEnv, skipFeatureAutoMapping: common.skipFeatureAutoMapping, experimentalImageMetadata: !args.skipImageMetadata, @@ -403,7 +401,7 @@ async function launchProject(params: DockerResolverParameters, args: FeaturesTes if (quiet) { // Launch container but don't await it to reduce output noise let isResolved = false; - const p = launch(options, disposables); + const p = launch(options, idLabels, disposables); p.then(function (res) { process.stdout.write('\n'); response = res; @@ -416,7 +414,7 @@ async function launchProject(params: DockerResolverParameters, args: FeaturesTes } } else { // Stream all the container setup logs. - response = await launch(options, disposables); + response = await launch(options, idLabels, disposables); } return { @@ -465,7 +463,6 @@ async function generateDockerParams(workspaceFolder: string, args: FeaturesTestC containerDataFolder: undefined, containerSystemDataFolder: undefined, mountWorkspaceGitRoot: false, - idLabels: [], configFile: undefined, overrideConfigFile: undefined, logLevel, diff --git a/src/spec-node/imageMetadata.ts b/src/spec-node/imageMetadata.ts index 69aba3fd5..38bd06d47 100644 --- a/src/spec-node/imageMetadata.ts +++ b/src/spec-node/imageMetadata.ts @@ -323,6 +323,10 @@ export async function getImageBuildInfo(params: DockerResolverParameters | Docke } else { + if (!config.image) { + throw new ContainerError({ description: 'No image information specified in devcontainer.json.' }); + } + return getImageBuildInfoFromImage(params, config.image, configWithRaw.substitute, experimentalImageMetadata); } @@ -361,12 +365,12 @@ export async function internalGetImageBuildInfoFromDockerfile(inspectDockerImage export const imageMetadataLabel = 'devcontainer.metadata'; -export function getImageMetadataFromContainer(containerDetails: ContainerDetails, devContainerConfig: SubstitutedConfig, featuresConfig: FeaturesConfig | undefined, idLabels: string[], experimentalImageMetadata: boolean, output: Log): SubstitutedConfig { +export function getImageMetadataFromContainer(containerDetails: ContainerDetails, devContainerConfig: SubstitutedConfig, featuresConfig: FeaturesConfig | undefined, idLabels: string[] | undefined, experimentalImageMetadata: boolean, output: Log): SubstitutedConfig { if (!(containerDetails.Config.Labels || {})[imageMetadataLabel] || !experimentalImageMetadata) { return getDevcontainerMetadata({ config: [], raw: [], substitute: devContainerConfig.substitute }, devContainerConfig, featuresConfig); } const metadata = internalGetImageMetadata(containerDetails, devContainerConfig.substitute, experimentalImageMetadata, output); - const hasIdLabels = Object.keys(envListToObj(idLabels)) + const hasIdLabels = !!idLabels && Object.keys(envListToObj(idLabels)) .every(label => (containerDetails.Config.Labels || {})[label]); if (hasIdLabels) { return { diff --git a/src/spec-node/singleContainer.ts b/src/spec-node/singleContainer.ts index 2066d25a1..d10453d2e 100644 --- a/src/spec-node/singleContainer.ts +++ b/src/spec-node/singleContainer.ts @@ -108,7 +108,7 @@ async function setupContainer(container: ContainerDetails, params: DockerResolve } function getDefaultName(config: DevContainerFromDockerfileConfig | DevContainerFromImageConfig, params: DockerResolverParameters) { - return 'image' in config ? config.image : getFolderImageName(params.common); + return 'image' in config && config.image ? config.image : getFolderImageName(params.common); } export async function buildNamedImageAndExtend(params: DockerResolverParameters, configWithRaw: SubstitutedConfig, additionalFeatures: Record>, canAddLabelsToContainer: boolean, argImageNames?: string[]): Promise<{ updatedImageName: string[]; imageMetadata: SubstitutedConfig; imageDetails: () => Promise; labels?: Record }> { const { config } = configWithRaw; diff --git a/src/test/cli.set-up.test.ts b/src/test/cli.set-up.test.ts new file mode 100644 index 000000000..e4a9d45af --- /dev/null +++ b/src/test/cli.set-up.test.ts @@ -0,0 +1,132 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { shellExec } from './testUtils'; + +const pkg = require('../../package.json'); + +describe('Dev Containers CLI', function () { + this.timeout('120s'); + + const tmp = path.relative(process.cwd(), path.join(__dirname, 'tmp')); + const cli = `npx --prefix ${tmp} devcontainer`; + + before('Install', 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 set-up', () => { + it('should succeed and run postAttachCommand from config', async () => { + + const containerId = (await shellExec(`docker run -d alpine:3.17 sleep inf`)).stdout.trim(); + + const res = await shellExec(`${cli} set-up --container-id ${containerId} --config ${__dirname}/configs/set-up-with-config/devcontainer.json`); + const response = JSON.parse(res.stdout); + assert.equal(response.outcome, 'success'); + + await shellExec(`docker exec ${containerId} test -f /postAttachCommand.txt`); + await shellExec(`docker rm -f ${containerId}`); + }); + + it('should succeed and run postCreateCommand from metadata', async () => { + + await shellExec(`docker build -t devcontainer-set-up-test ${__dirname}/configs/set-up-with-metadata`); + const containerId = (await shellExec(`docker run -d devcontainer-set-up-test sleep inf`)).stdout.trim(); + + const res = await shellExec(`${cli} set-up --container-id ${containerId}`); + const response = JSON.parse(res.stdout); + assert.equal(response.outcome, 'success'); + + await shellExec(`docker exec ${containerId} test -f /postCreateCommand.txt`); + await shellExec(`docker rm -f ${containerId}`); + }); + }); + + describe('Command run-user-commands', () => { + it('should succeed and run postAttachCommand from config', async () => { + + const containerId = (await shellExec(`docker run -d alpine:3.17 sleep inf`)).stdout.trim(); + + const res = await shellExec(`${cli} run-user-commands --container-id ${containerId} --config ${__dirname}/configs/set-up-with-config/devcontainer.json`); + const response = JSON.parse(res.stdout); + assert.equal(response.outcome, 'success'); + + await shellExec(`docker exec ${containerId} test -f /postAttachCommand.txt`); + await shellExec(`docker rm -f ${containerId}`); + }); + + it('should succeed and run postCreateCommand from metadata', async () => { + + await shellExec(`docker build -t devcontainer-set-up-test ${__dirname}/configs/set-up-with-metadata`); + const containerId = (await shellExec(`docker run -d devcontainer-set-up-test sleep inf`)).stdout.trim(); + + const res = await shellExec(`${cli} run-user-commands --container-id ${containerId}`); + const response = JSON.parse(res.stdout); + assert.equal(response.outcome, 'success'); + + await shellExec(`docker exec ${containerId} test -f /postCreateCommand.txt`); + await shellExec(`docker rm -f ${containerId}`); + }); + }); + + describe('Command read-configuration', () => { + it('should succeed and return postAttachCommand from config', async () => { + + const containerId = (await shellExec(`docker run -d alpine:3.17 sleep inf`)).stdout.trim(); + + const res = await shellExec(`${cli} read-configuration --container-id ${containerId} --config ${__dirname}/configs/set-up-with-config/devcontainer.json --include-merged-configuration`); + const response = JSON.parse(res.stdout); + assert.ok(response.configuration.postAttachCommand); + assert.strictEqual(response.mergedConfiguration.postAttachCommands.length, 1); + + await shellExec(`docker rm -f ${containerId}`); + }); + + it('should succeed and return postCreateCommand from metadata', async () => { + + await shellExec(`docker build -t devcontainer-set-up-test ${__dirname}/configs/set-up-with-metadata`); + const containerId = (await shellExec(`docker run -d devcontainer-set-up-test sleep inf`)).stdout.trim(); + + const res = await shellExec(`${cli} read-configuration --container-id ${containerId} --include-merged-configuration`); + const response = JSON.parse(res.stdout); + assert.strictEqual(response.mergedConfiguration.postCreateCommands.length, 1); + + await shellExec(`docker rm -f ${containerId}`); + }); + }); + + describe('Command exec', () => { + it('should succeed with config', async () => { + + const containerId = (await shellExec(`docker run -d alpine:3.17 sleep inf`)).stdout.trim(); + + const res = await shellExec(`${cli} exec --container-id ${containerId} --config ${__dirname}/configs/set-up-with-config/devcontainer.json echo test-output`); + const response = JSON.parse(res.stdout); + console.log(res.stderr); + assert.equal(response.outcome, 'success'); + assert.match(res.stderr, /test-output/); + + await shellExec(`docker rm -f ${containerId}`); + }); + + it('should succeed with metadata', async () => { + + await shellExec(`docker build -t devcontainer-set-up-test ${__dirname}/configs/set-up-with-metadata`); + const containerId = (await shellExec(`docker run -d devcontainer-set-up-test sleep inf`)).stdout.trim(); + + const res = await shellExec(`${cli} exec --container-id ${containerId} echo test-output`); + const response = JSON.parse(res.stdout); + console.log(res.stderr); + assert.equal(response.outcome, 'success'); + assert.match(res.stderr, /test-output/); + + await shellExec(`docker rm -f ${containerId}`); + }); + }); +}); diff --git a/src/test/configs/set-up-with-config/devcontainer.json b/src/test/configs/set-up-with-config/devcontainer.json new file mode 100644 index 000000000..5dfb95588 --- /dev/null +++ b/src/test/configs/set-up-with-config/devcontainer.json @@ -0,0 +1,3 @@ +{ + "postAttachCommand": "touch /postAttachCommand.txt" +} \ No newline at end of file diff --git a/src/test/configs/set-up-with-metadata/Dockerfile b/src/test/configs/set-up-with-metadata/Dockerfile new file mode 100644 index 000000000..75367cdca --- /dev/null +++ b/src/test/configs/set-up-with-metadata/Dockerfile @@ -0,0 +1,3 @@ +FROM alpine:3.17 + +LABEL "devcontainer.metadata"="{ \"postCreateCommand\": \"touch /postCreateCommand.txt\" }"