Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 <cmd> [args..] Execute a command on a running dev container

Options:
Expand Down
157 changes: 156 additions & 1 deletion src/spec-node/devContainersSpecCLI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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 <name>=<value>');
}
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<ReturnType<typeof stopOptions>>;
type DownArgs = UnpackArgv<ReturnType<typeof downOptions>>;

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<unknown> | 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<void>((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.' },
Expand Down
4 changes: 2 additions & 2 deletions src/spec-shutdown/dockerUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> | 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
Expand Down
96 changes: 96 additions & 0 deletions src/test/cli.stop-down.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});