diff --git a/README.md b/README.md index 39b043ba0..1043f214f 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,8 @@ This CLI is in active development. Current status: - [x] `devcontainer upgrade` - Upgrade lockfile features - [x] `devcontainer features <...>` - Tools to assist in authoring and testing [Dev Container Features](https://containers.dev/implementors/features/) - [x] `devcontainer templates <...>` - Tools to assist in authoring and testing [Dev Container Templates](https://containers.dev/implementors/templates/) -- [ ] `devcontainer stop` - Stops containers -- [ ] `devcontainer down` - Stops and deletes containers +- [x] `devcontainer stop` - Stops containers +- [x] `devcontainer down` - Stops and deletes containers Lockfiles (`.devcontainer-lock.json`) are generated by default when running `build` or `up` to pin feature versions for reproducible builds. Use `--no-lockfile` to opt out, or `--frozen-lockfile` to enforce an existing lockfile. @@ -78,6 +78,8 @@ Commands: devcontainer read-configuration Read configuration devcontainer features Features commands devcontainer templates Templates commands + devcontainer stop Stop a running dev container + devcontainer down Stop and remove a dev container devcontainer exec [args..] Execute a command on a running dev container Options: diff --git a/src/spec-node/devContainersSpecCLI.ts b/src/spec-node/devContainersSpecCLI.ts index 782232e11..cf90bbcf9 100644 --- a/src/spec-node/devContainersSpecCLI.ts +++ b/src/spec-node/devContainersSpecCLI.ts @@ -16,7 +16,7 @@ import { ContainerError } from '../spec-common/errors'; import { Log, LogDimensions, LogLevel, makeLog, mapLogLevel } from '../spec-utils/log'; import { probeRemoteEnv, runLifecycleHooks, runRemoteCommand, UserEnvProbe, setupInContainer } from '../spec-common/injectHeadless'; import { extendImage } from './containerFeatures'; -import { dockerCLI, DockerCLIParameters, dockerPtyCLI, inspectContainer } from '../spec-shutdown/dockerUtils'; +import { dockerCLI, DockerCLIParameters, dockerComposeCLI, dockerPtyCLI, inspectContainer, removeContainer } from '../spec-shutdown/dockerUtils'; import { buildAndExtendDockerCompose, dockerComposeCLIConfig, getDefaultImageName, getProjectName, readDockerComposeConfig, readVersionPrefix } from './dockerCompose'; import { DevContainerFromDockerComposeConfig, DevContainerFromDockerfileConfig, getDockerComposeFilePaths } from '../spec-configuration/configuration'; import { workspaceFromPath } from '../spec-utils/workspaces'; @@ -97,6 +97,8 @@ const mountRegex = /^type=(bind|volume),source=([^,]+),target=([^,]+)(?:,externa 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); + y.command('stop', 'Stop a running dev container', stopOptions, stopHandler); + y.command('down', 'Stop and remove a dev container', downOptions, downHandler); y.command('outdated', 'Show current and available versions', outdatedOptions, outdatedHandler); y.command('upgrade', 'Upgrade lockfile', featuresUpgradeOptions, featuresUpgradeHandler); y.command('features', 'Features commands', (y: Argv) => { @@ -1303,6 +1305,159 @@ async function outdated({ process.exit(0); } +function containerLifecycleOptions(y: Argv) { + return y.options({ + 'docker-path': { type: 'string', description: 'Docker CLI path.' }, + 'docker-compose-path': { type: 'string', description: 'Docker Compose CLI path.' }, + 'workspace-folder': { type: 'string', description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path. If --container-id, --id-label, and --workspace-folder are not provided, this defaults to the current directory.' }, + 'container-id': { type: 'string', description: 'Id of the container.' }, + '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.' }, + 'config': { type: 'string', description: 'devcontainer.json path. The default is to use .devcontainer/devcontainer.json or, if that does not exist, .devcontainer.json in the workspace folder.' }, + 'log-level': { choices: ['info' as 'info', 'debug' as 'debug', 'trace' as 'trace'], default: 'info' as 'info', description: 'Log level.' }, + 'log-format': { choices: ['text' as 'text', 'json' as 'json'], default: 'text' as 'text', description: 'Log format.' }, + }) + .check(argv => { + const idLabels = (argv['id-label'] && (Array.isArray(argv['id-label']) ? argv['id-label'] : [argv['id-label']])) as string[] | undefined; + 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']) { + argv['workspace-folder'] = process.cwd(); + } + return true; + }); +} + +function stopOptions(y: Argv) { + return containerLifecycleOptions(y); +} + +function downOptions(y: Argv) { + return containerLifecycleOptions(y) + .options({ + 'remove-volumes': { type: 'boolean', default: false, description: 'Also remove anonymous volumes attached to the container. For Docker Compose configurations, named volumes declared in the Compose file are removed as well.' }, + }); +} + +type StopArgs = UnpackArgv>; +type DownArgs = UnpackArgv>; + +function stopHandler(args: StopArgs) { + runAsyncHandler(stopOrDown.bind(null, args, false)); +} + +function downHandler(args: DownArgs) { + runAsyncHandler(stopOrDown.bind(null, args, true)); +} + +async function stopOrDown({ + 'docker-path': dockerPath, + 'docker-compose-path': dockerComposePath, + 'workspace-folder': workspaceFolderArg, + 'container-id': containerId, + 'id-label': idLabel, + config: configParam, + 'log-level': logLevel, + 'log-format': logFormat, + 'remove-volumes': removeVolumes, +}: StopArgs & { 'remove-volumes'?: boolean }, remove: boolean) { + const disposables: (() => Promise | undefined)[] = []; + const dispose = async () => { + await Promise.all(disposables.map(d => d())); + }; + let output: Log | undefined; + try { + const workspaceFolder = workspaceFolderArg ? path.resolve(process.cwd(), workspaceFolderArg) : undefined; + const providedIdLabels = idLabel ? Array.isArray(idLabel) ? idLabel as string[] : [idLabel] : undefined; + const configFile = configParam ? URI.file(path.resolve(process.cwd(), configParam)) : undefined; + const cliHost = await getCLIHost(workspaceFolder || process.cwd(), loadNativeModule, logFormat === 'text'); + const sessionStart = new Date(); + const pkg = getPackageConfig(); + output = createLog({ + logLevel: mapLogLevel(logLevel), + logFormat, + log: text => process.stderr.write(text), + terminalDimensions: undefined, + }, pkg, sessionStart, disposables); + + const workspace = workspaceFolder ? workspaceFromPath(cliHost.path, workspaceFolder) : undefined; + const configPath = configFile || (workspace && (await getDevContainerConfigPathIn(cliHost, workspace.configFolderPath) || getDefaultDevContainerConfigPath(cliHost, workspace.configFolderPath))); + + const dockerCLICmd = dockerPath || 'docker'; + const buildPlatformInfo = { + os: mapNodeOSToGOOS(cliHost.platform), + arch: mapNodeArchitectureToGOARCH(cliHost.arch), + }; + const params: DockerCLIParameters = { + cliHost, + dockerCLI: dockerCLICmd, + dockerComposeCLI: dockerComposeCLIConfig({ + exec: cliHost.exec, + env: cliHost.env, + output, + }, dockerCLICmd, dockerComposePath || 'docker-compose'), + env: cliHost.env, + output, + buildPlatformInfo, + targetPlatformInfo: buildPlatformInfo, + ociAuthDiagnostics: createOCIAuthDiagnostics(), + }; + + const { container } = await findContainerAndIdLabels(params, containerId, providedIdLabels, workspaceFolder, configPath?.fsPath); + const composeProjectName = container?.Config.Labels?.['com.docker.compose.project']; + if (!container) { + output.write('Dev container not found.'); + } else if (composeProjectName) { + // Compose v2 resolves the project from its labels. Compose v1 still needs the Compose files. + const composeCLI = await params.dockerComposeCLI(); + const composeFileArgs: string[] = []; + if (!composeCLI.args.includes('compose')) { + const configFiles = container.Config.Labels?.['com.docker.compose.project.config_files']?.split(',') ?? []; + for (const file of configFiles) { + if (await cliHost.isFile(file)) { + composeFileArgs.push('-f', file); + } + } + } + const composeArgs = ['-p', composeProjectName, remove ? 'down' : 'stop']; + if (remove && removeVolumes) { + composeArgs.push('-v'); + } + const text = `Running ${remove ? 'down' : 'stop'} for Compose project ${composeProjectName}`; + const start = output.start(text); + await dockerComposeCLI(params, ...composeFileArgs, ...composeArgs); + output.stop(text, start); + } else if (remove) { + const text = 'Removing container'; + const start = output.start(text); + await removeContainer(params, container.Id, removeVolumes); + output.stop(text, start); + } else { + const text = 'Stopping container'; + const start = output.start(text); + await dockerCLI(params, 'stop', container.Id); + output.stop(text, start); + } + await new Promise((resolve, reject) => { + process.stdout.write(JSON.stringify({ + outcome: 'success', + containerId: container?.Id, + composeProjectName, + }) + '\n', err => err ? reject(err) : resolve()); + }); + } catch (err) { + if (output) { + output.write(err && (err.stack || err.message) || String(err)); + } else { + console.error(err); + } + await dispose(); + process.exit(1); + } + await dispose(); + process.exit(0); +} + function execOptions(y: Argv) { return y.options({ 'user-data-folder': { type: 'string', description: 'Host path to a directory that is intended to be persisted and share state between sessions.' }, diff --git a/src/spec-shutdown/dockerUtils.ts b/src/spec-shutdown/dockerUtils.ts index 3a15aa6b7..afb8a480f 100644 --- a/src/spec-shutdown/dockerUtils.ts +++ b/src/spec-shutdown/dockerUtils.ts @@ -174,14 +174,14 @@ export async function listContainers(params: DockerCLIParameters | PartialExecPa .filter(s => !!s); } -export async function removeContainer(params: DockerCLIParameters | PartialExecParameters | DockerResolverParameters, nameOrId: string) { +export async function removeContainer(params: DockerCLIParameters | PartialExecParameters | DockerResolverParameters, nameOrId: string, removeVolumes = false) { const useEvents = !('cliVariant' in params && params.cliVariant === CLIVariant.Wslc); let eventsProcess: Exec | undefined; let removedSeenP: Promise | undefined; try { for (let i = 0, n = 7; i < n; i++) { try { - await dockerCLI(params, 'rm', '-f', nameOrId); + await dockerCLI(params, 'rm', '-f', ...(removeVolumes ? ['-v'] : []), nameOrId); return; } catch (err) { // https://github.com/microsoft/vscode-remote-release/issues/6509 diff --git a/src/test/cli.stop-down.test.ts b/src/test/cli.stop-down.test.ts new file mode 100644 index 000000000..212cb799a --- /dev/null +++ b/src/test/cli.stop-down.test.ts @@ -0,0 +1,96 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import * as path from 'path'; +import { devContainerDown, devContainerUp, shellExec } from './testUtils'; + +const pkg = require('../../package.json'); + +describe('Dev Containers CLI', function () { + this.timeout('240s'); + + 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`); + }); + + async function containerState(containerId: string) { + const res = await shellExec(`docker inspect --format '{{.State.Status}}' ${containerId}`, undefined, true, true); + return res.error ? undefined : res.stdout.trim(); + } + + describe('Command stop and down with an image config', () => { + const testFolder = `${__dirname}/configs/image`; + let containerId: string | null = null; + + beforeEach(async () => containerId = (await devContainerUp(cli, testFolder)).containerId); + afterEach(async () => await devContainerDown({ containerId, doNotThrow: true })); + + it('should stop and restart the container', async () => { + const stopRes = await shellExec(`${cli} stop --workspace-folder ${testFolder}`); + const stopResponse = JSON.parse(stopRes.stdout); + assert.equal(stopResponse.outcome, 'success'); + assert.equal(stopResponse.containerId, containerId); + assert.equal(await containerState(containerId!), 'exited'); + + const upResponse = await devContainerUp(cli, testFolder); + assert.equal(upResponse.containerId, containerId); + assert.equal(await containerState(containerId!), 'running'); + }); + + it('should remove the container', async () => { + const res = await shellExec(`${cli} down --workspace-folder ${testFolder}`); + const response = JSON.parse(res.stdout); + assert.equal(response.outcome, 'success'); + assert.equal(response.containerId, containerId); + assert.equal(await containerState(containerId!), undefined); + }); + + it('should remove the container by --container-id', async () => { + const res = await shellExec(`${cli} down --container-id ${containerId}`); + const response = JSON.parse(res.stdout); + assert.equal(response.outcome, 'success'); + assert.equal(await containerState(containerId!), undefined); + }); + + it('should succeed when the container is already removed', async () => { + await shellExec(`docker rm -f ${containerId}`); + const res = await shellExec(`${cli} down --workspace-folder ${testFolder}`); + const response = JSON.parse(res.stdout); + assert.equal(response.outcome, 'success'); + assert.equal(response.containerId, undefined); + }); + }); + + describe('Command stop and down with a Docker Compose config', () => { + const testFolder = `${__dirname}/configs/compose-image-without-features-minimal`; + let containerId: string | null = null; + let composeProjectName: string | undefined = undefined; + + beforeEach(async () => ({ containerId, composeProjectName } = await devContainerUp(cli, testFolder))); + afterEach(async () => await devContainerDown({ composeProjectName, doNotThrow: true })); + + it('should stop the Compose project', async () => { + const res = await shellExec(`${cli} stop --workspace-folder ${testFolder}`); + const response = JSON.parse(res.stdout); + assert.equal(response.outcome, 'success'); + assert.equal(response.composeProjectName, composeProjectName); + assert.equal(await containerState(containerId!), 'exited'); + }); + + it('should remove the Compose project', async () => { + const res = await shellExec(`${cli} down --workspace-folder ${testFolder}`); + const response = JSON.parse(res.stdout); + assert.equal(response.outcome, 'success'); + assert.equal(response.composeProjectName, composeProjectName); + assert.equal(await containerState(containerId!), undefined); + }); + }); +});