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
46 changes: 42 additions & 4 deletions registry/coder/modules/devcontainers-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,53 @@ tags: [devcontainers]

# devcontainers-cli

The devcontainers-cli module provides an easy way to install [`@devcontainers/cli`](https://github.com/devcontainers/cli) into a workspace. It can be used within any workspace as it runs only if
@devcontainers/cli is not installed yet.
`npm` is required and should be pre-installed in order for the module to work.
This module installs [`@devcontainers/cli`](https://github.com/devcontainers/cli) when a Coder agent starts. It makes the `devcontainer` command available in the agent-managed binary directory, without requiring `sudo`, so workspace startup scripts and users can run Dev Container commands.

The module uses the first available package manager in this order: Yarn, npm, then pnpm. Docker and one of these package managers must already be installed in the workspace image. If `devcontainer` is already on `PATH`, the module keeps that installation and skips downloading the package.

```tf
module "devcontainers-cli" {
source = "registry.coder.com/coder/devcontainers-cli/coder"
version = "1.1.0"
version = "1.2.0"
agent_id = coder_agent.example.id
start_blocks_login = false
}
```

## Configuration

By default, the module installs the `latest` npm dist-tag without delaying workspace login. Set `start_blocks_login = true` when `devcontainer` must be ready before a user can connect.

## Pin a CLI version

Use an exact version for reproducible workspace builds:

```tf
module "devcontainers-cli" {
source = "registry.coder.com/coder/devcontainers-cli/coder"
version = "1.2.0"
agent_id = coder_agent.example.id
devcontainers_cli_version = "0.80.0"
}
```

## Use an internal registry

Restricted environments can route installation through an npm-compatible registry mirror:

```tf
module "devcontainers-cli" {
source = "registry.coder.com/coder/devcontainers-cli/coder"
version = "1.2.0"
agent_id = coder_agent.example.id
registry_url = "https://registry.example.com/npm"
}
```

When `registry_url` is unset, the selected package manager uses its existing registry configuration. Authentication remains in that package manager's configuration; the module does not accept or store registry credentials.

## Network and air-gapped environments

During installation, the selected package manager contacts `registry_url`, or its configured registry when the variable is unset, to resolve and download `@devcontainers/cli` and its dependencies. An internal mirror must serve both package metadata and referenced package artifacts.

The module makes no network requests after installation. Commands run through `devcontainer` can still contact the Docker daemon, image registries, and sources referenced by the workspace's Dev Container configuration. For a fully air-gapped workspace, bake the CLI and required container artifacts into the image; when `devcontainer` is already on `PATH`, this module skips installation.
260 changes: 151 additions & 109 deletions registry/coder/modules/devcontainers-cli/main.test.ts
Original file line number Diff line number Diff line change
@@ -1,148 +1,190 @@
import { describe, expect, it } from "bun:test";
import { describe, expect, it, setDefaultTimeout } from "bun:test";
import {
execContainer,
executeScriptInContainer,
findResourceInstance,
removeContainer,
runContainer,
runTerraformApply,
runTerraformInit,
testRequiredVariables,
type TerraformState,
} from "~test";
import {
SCRIPT_BIN_DIR,
SCRIPT_DATA_DIR,
executeInAlpine,
packageManagerStub,
setupPackageManager,
writeExecutable,
} from "./test-util";

const executeScriptInContainerWithPackageManager = async (
state: TerraformState,
image: string,
packageManager: string,
shell = "sh",
): Promise<{
exitCode: number;
stdout: string[];
stderr: string[];
}> => {
const instance = findResourceInstance(state, "coder_script");
const id = await runContainer(image);

// Install the specified package manager
if (packageManager === "npm") {
await execContainer(id, [shell, "-c", "apk add nodejs npm"]);
} else if (packageManager === "pnpm") {
await execContainer(id, [
shell,
"-c",
`wget -qO- https://get.pnpm.io/install.sh | ENV="$HOME/.shrc" SHELL="$(which sh)" sh -`,
]);
} else if (packageManager === "yarn") {
await execContainer(id, [
shell,
"-c",
"apk add nodejs npm && npm install -g yarn",
]);
}

const pathResp = await execContainer(id, [shell, "-c", "echo $PATH"]);
const path = pathResp.stdout.trim();

console.log(path);

await execContainer(id, [shell, "-c", "mkdir -p /tmp/coder-script-data"]);

const resp = await execContainer(
id,
[shell, "-c", instance.script],
[
"--env",
"CODER_SCRIPT_BIN_DIR=/tmp/coder-script-data/bin",
"--env",
"CODER_SCRIPT_DATA_DIR=/tmp/coder-script-data",
"--env",
`PATH=${path}:/tmp/coder-script-data/bin`,
],
);
const stdout = resp.stdout.trim().split("\n");
const stderr = resp.stderr.trim().split("\n");
return {
exitCode: resp.exitCode,
stdout,
stderr,
};
};
setDefaultTimeout(120 * 1000);

describe("devcontainers-cli", async () => {
await runTerraformInit(import.meta.dir);

const defaultState = await runTerraformApply(import.meta.dir, {
agent_id: "some-agent-id",
});

testRequiredVariables(import.meta.dir, {
agent_id: "some-agent-id",
});

it("misses all package managers", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "some-agent-id",
it("skips installation when devcontainer is already available", async () => {
const output = await executeInAlpine(defaultState, async (containerID) => {
await writeExecutable(
containerID,
"/usr/local/bin/devcontainer",
"#!/bin/sh\nexit 0\n",
);
});

expect(output.exitCode).toBe(0);
expect(output.stdout).toEqual([
"🥳 @devcontainers/cli is already installed into /usr/local/bin/devcontainer!",
]);
});

it("fails when no supported package manager is available", async () => {
const output = await executeInAlpine(defaultState, async (containerID) => {
await writeExecutable(
containerID,
"/usr/local/bin/docker",
"#!/bin/sh\nexit 0\n",
);
});
const output = await executeScriptInContainer(state, "docker:dind");

expect(output.exitCode).toBe(1);
expect(output.stderr).toEqual([
"ERROR: No supported package manager (npm, pnpm, yarn) is installed. Please install one first.",
]);
}, 15000);
});

it("installs devcontainers-cli with npm", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "some-agent-id",
it("warns when Docker is unavailable without blocking installation", async () => {
const output = await executeInAlpine(defaultState, async (containerID) => {
await writeExecutable(
containerID,
"/usr/local/bin/npm",
packageManagerStub(),
);
});

const output = await executeScriptInContainerWithPackageManager(
state,
"docker:dind",
"npm",
);
expect(output.exitCode).toBe(0);

expect(output.stdout[0]).toEqual(
"Installing @devcontainers/cli using npm...",
expect(output.stdout[0]).toBe(
"WARNING: Docker was not found but is required to use @devcontainers/cli, please make sure it is available.",
);
expect(output.stdout[output.stdout.length - 1]).toEqual(
"🥳 @devcontainers/cli has been installed into /usr/local/bin/devcontainer!",
expect(output.stdout.at(-1)).toBe(
`🥳 @devcontainers/cli has been installed into ${SCRIPT_BIN_DIR}/devcontainer!`,
);
}, 15000);
});

it("installs devcontainers-cli with yarn", async () => {
it("passes the configured version and registry to every package manager", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "some-agent-id",
devcontainers_cli_version: "0.80.0",
registry_url: "https://registry.example.com/npm",
});
const cases = [
[
"npm",
[
"install",
"--global",
"@devcontainers/cli@0.80.0",
"--prefix",
SCRIPT_DATA_DIR,
],
],
["pnpm", ["add", "--global", "@devcontainers/cli@0.80.0"]],
[
"yarn",
[
"global",
"add",
"@devcontainers/cli@0.80.0",
"--prefix",
SCRIPT_DATA_DIR,
],
],
] as const;

for (const [packageManager, expectedArgs] of cases) {
const output = await executeInAlpine(state, async (containerID) => {
await setupPackageManager(containerID, packageManager);
});

expect(output.exitCode).toBe(0);
expect(output.packageManagerArgs).toEqual([
...expectedArgs,
"--registry",
"https://registry.example.com/npm",
]);
}
});

const output = await executeScriptInContainerWithPackageManager(
state,
"docker:dind",
"yarn",
);
expect(output.exitCode).toBe(0);
it("preserves package-manager failures", async () => {
const output = await executeInAlpine(defaultState, async (containerID) => {
await setupPackageManager(
containerID,
"npm",
packageManagerStub(17, false),
);
});

expect(output.stdout[0]).toEqual(
"Installing @devcontainers/cli using yarn...",
);
expect(output.stdout[output.stdout.length - 1]).toEqual(
"🥳 @devcontainers/cli has been installed into /tmp/coder-script-data/bin/devcontainer!",
);
}, 15000);
expect(output.exitCode).toBe(17);
expect(output.stderr).toEqual(["Failed to install @devcontainers/cli"]);
});

it("displays warning if docker is not installed", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "some-agent-id",
it("fails when installation does not expose devcontainer on PATH", async () => {
const output = await executeInAlpine(defaultState, async (containerID) => {
await setupPackageManager(
containerID,
"npm",
packageManagerStub(0, false),
);
});

const output = await executeScriptInContainerWithPackageManager(
state,
"alpine",
"npm",
);
expect(output.exitCode).toBe(0);
expect(output.exitCode).toBe(1);
expect(output.stderr).toEqual([
"Installation completed but 'devcontainer' command not found in PATH",
]);
});

expect(output.stdout[0]).toEqual(
"WARNING: Docker was not found but is required to use @devcontainers/cli, please make sure it is available.",
);
expect(output.stdout[output.stdout.length - 1]).toEqual(
"🥳 @devcontainers/cli has been installed into /usr/local/bin/devcontainer!",
);
}, 15000);
it("installs and runs a pinned devcontainers CLI with npm", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "some-agent-id",
devcontainers_cli_version: "0.80.0",
});
const instance = findResourceInstance(state, "coder_script");
const containerID = await runContainer("node:22-alpine");
const path = `/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${SCRIPT_BIN_DIR}`;
const env = [
"--env",
`CODER_SCRIPT_DATA_DIR=${SCRIPT_DATA_DIR}`,
"--env",
`CODER_SCRIPT_BIN_DIR=${SCRIPT_BIN_DIR}`,
"--env",
`PATH=${path}`,
];

try {
await execContainer(containerID, ["mkdir", "-p", SCRIPT_BIN_DIR]);
const install = await execContainer(
containerID,
["sh", "-c", instance.script],
env,
);
expect(install.exitCode).toBe(0);

const version = await execContainer(
containerID,
["devcontainer", "--version"],
env,
);
expect(version.exitCode).toBe(0);
expect(version.stdout.trim()).toBe("0.80.0");
} finally {
await removeContainer(containerID);
}
});
});
Loading
Loading