diff --git a/registry/coder/modules/devcontainers-cli/README.md b/registry/coder/modules/devcontainers-cli/README.md index 771be25da..e150dde7c 100644 --- a/registry/coder/modules/devcontainers-cli/README.md +++ b/registry/coder/modules/devcontainers-cli/README.md @@ -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. diff --git a/registry/coder/modules/devcontainers-cli/main.test.ts b/registry/coder/modules/devcontainers-cli/main.test.ts index 5a4d34e8e..4830db879 100644 --- a/registry/coder/modules/devcontainers-cli/main.test.ts +++ b/registry/coder/modules/devcontainers-cli/main.test.ts @@ -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); + } + }); }); diff --git a/registry/coder/modules/devcontainers-cli/main.tf b/registry/coder/modules/devcontainers-cli/main.tf index 16fa35fe9..1ac4a0416 100644 --- a/registry/coder/modules/devcontainers-cli/main.tf +++ b/registry/coder/modules/devcontainers-cli/main.tf @@ -10,21 +10,46 @@ terraform { } variable "agent_id" { - type = string description = "The ID of a Coder agent." + type = string } variable "start_blocks_login" { + description = "Whether workspace login waits for the devcontainers CLI installation to finish." type = bool default = false - description = "Boolean, This option determines whether users can log in immediately or must wait for the workspace to finish running this script upon startup." +} + +variable "devcontainers_cli_version" { + description = "The @devcontainers/cli version or npm dist-tag to install." + type = string + default = "latest" + + validation { + condition = length(trimspace(var.devcontainers_cli_version)) > 0 + error_message = "devcontainers_cli_version must not be empty." + } +} + +variable "registry_url" { + description = "Optional npm-compatible registry URL used to install @devcontainers/cli. When unset, the selected package manager uses its configured registry." + type = string + default = null + + validation { + condition = var.registry_url == null || can(regex("^https?://[^\\s]+$", var.registry_url)) + error_message = "registry_url must be null or a valid HTTP(S) URL." + } } resource "coder_script" "devcontainers-cli" { - agent_id = var.agent_id - display_name = "devcontainers-cli" - icon = "/icon/devcontainers.svg" - script = templatefile("${path.module}/run.sh", {}) + agent_id = var.agent_id + display_name = "devcontainers-cli" + icon = "/icon/devcontainers.svg" + script = templatefile("${path.module}/run.sh", { + DEVCONTAINERS_CLI_VERSION_B64 = base64encode(var.devcontainers_cli_version) + REGISTRY_URL_B64 = var.registry_url != null ? base64encode(var.registry_url) : "" + }) run_on_start = true start_blocks_login = var.start_blocks_login } diff --git a/registry/coder/modules/devcontainers-cli/main.tftest.hcl b/registry/coder/modules/devcontainers-cli/main.tftest.hcl new file mode 100644 index 000000000..ca5635459 --- /dev/null +++ b/registry/coder/modules/devcontainers-cli/main.tftest.hcl @@ -0,0 +1,79 @@ +mock_provider "coder" {} + +variables { + agent_id = "test-agent-id" +} + +run "defaults" { + command = plan + + assert { + condition = resource.coder_script.devcontainers-cli.agent_id == var.agent_id + error_message = "The install script must use the configured agent ID." + } + + assert { + condition = resource.coder_script.devcontainers-cli.run_on_start + error_message = "The install script must run when the workspace starts." + } + + assert { + condition = !resource.coder_script.devcontainers-cli.start_blocks_login + error_message = "Workspace login must remain non-blocking by default." + } + + assert { + condition = strcontains(resource.coder_script.devcontainers-cli.script, base64encode("latest")) + error_message = "The rendered script must contain the encoded default version." + } +} + +run "custom_install_source" { + command = plan + + variables { + devcontainers_cli_version = "0.80.0" + registry_url = "https://registry.example.com/npm" + start_blocks_login = true + } + + assert { + condition = resource.coder_script.devcontainers-cli.start_blocks_login + error_message = "The install script must preserve the configured login-blocking behavior." + } + + assert { + condition = strcontains(resource.coder_script.devcontainers-cli.script, base64encode(var.devcontainers_cli_version)) + error_message = "The rendered script must contain the encoded custom version." + } + + assert { + condition = strcontains(resource.coder_script.devcontainers-cli.script, base64encode(var.registry_url)) + error_message = "The rendered script must contain the encoded custom registry URL." + } + + assert { + condition = !strcontains(resource.coder_script.devcontainers-cli.script, var.registry_url) + error_message = "The rendered script must not interpolate the raw registry URL." + } +} + +run "rejects_empty_version" { + command = plan + + variables { + devcontainers_cli_version = " " + } + + expect_failures = [var.devcontainers_cli_version] +} + +run "rejects_invalid_registry_url" { + command = plan + + variables { + registry_url = "registry.example.com/npm" + } + + expect_failures = [var.registry_url] +} diff --git a/registry/coder/modules/devcontainers-cli/run.sh b/registry/coder/modules/devcontainers-cli/run.sh index d155cd597..b1fda284d 100755 --- a/registry/coder/modules/devcontainers-cli/run.sh +++ b/registry/coder/modules/devcontainers-cli/run.sh @@ -1,14 +1,33 @@ #!/usr/bin/env sh +# shellcheck shell=sh + +# shellcheck disable=SC2016 # Terraform replaces this literal placeholder. +DEVCONTAINERS_CLI_VERSION=$(printf '%s' '${DEVCONTAINERS_CLI_VERSION_B64}' | base64 -d) || { + echo "ERROR: Failed to decode devcontainers_cli_version." >&2 + exit 1 +} + +REGISTRY_URL="" +# shellcheck disable=SC2016 # Terraform replaces this literal placeholder. +if [ -n '${REGISTRY_URL_B64}' ]; then + # shellcheck disable=SC2016 # Terraform replaces this literal placeholder. + REGISTRY_URL=$(printf '%s' '${REGISTRY_URL_B64}' | base64 -d) || { + echo "ERROR: Failed to decode registry_url." >&2 + exit 1 + } +fi + +PACKAGE_SPEC="@devcontainers/cli@$DEVCONTAINERS_CLI_VERSION" # We want to cd into `$CODER_SCRIPT_DATA_DIR` as the current directory # might contain a `package.json` with `packageManager` set to something # other than the detected package manager. When this happens, it can # cause the installation to fail. -cd "$CODER_SCRIPT_DATA_DIR" || exit +cd "$CODER_SCRIPT_DATA_DIR" || exit 1 # If @devcontainers/cli is already installed, we can skip -if command -v devcontainer > /dev/null 2>&1; then - echo "🥳 @devcontainers/cli is already installed into $(which devcontainer)!" +if DEVCONTAINER_PATH=$(command -v devcontainer 2> /dev/null); then + echo "🥳 @devcontainers/cli is already installed into $DEVCONTAINER_PATH!" exit 0 fi @@ -29,34 +48,47 @@ else exit 1 fi +INSTALL_PREFIX=$(dirname "$CODER_SCRIPT_BIN_DIR") + install() { - echo "Installing @devcontainers/cli using $PACKAGE_MANAGER..." - if [ "$PACKAGE_MANAGER" = "npm" ]; then - npm install -g @devcontainers/cli - elif [ "$PACKAGE_MANAGER" = "pnpm" ]; then - # Check if PNPM_HOME is set, if not, set it to the script's bin directory - # pnpm needs this to be set to install binaries - # coder agent ensures this part is part of the PATH - # so that the devcontainer command is available - if [ -z "$PNPM_HOME" ]; then + echo "Installing $PACKAGE_SPEC using $PACKAGE_MANAGER..." + + case "$PACKAGE_MANAGER" in + npm) + set -- install --global "$PACKAGE_SPEC" --prefix "$INSTALL_PREFIX" + ;; + pnpm) PNPM_HOME="$CODER_SCRIPT_BIN_DIR" export PNPM_HOME - fi - pnpm add -g @devcontainers/cli - elif [ "$PACKAGE_MANAGER" = "yarn" ]; then - yarn global add @devcontainers/cli --prefix "$(dirname "$CODER_SCRIPT_BIN_DIR")" + set -- add --global "$PACKAGE_SPEC" + ;; + yarn) + set -- global add "$PACKAGE_SPEC" --prefix "$INSTALL_PREFIX" + ;; + *) + echo "ERROR: Unsupported package manager: $PACKAGE_MANAGER" >&2 + return 1 + ;; + esac + + if [ -n "$REGISTRY_URL" ]; then + set -- "$@" --registry "$REGISTRY_URL" fi + + "$PACKAGE_MANAGER" "$@" } -if ! install; then +install +INSTALL_EXIT=$? +if [ "$INSTALL_EXIT" -ne 0 ]; then echo "Failed to install @devcontainers/cli" >&2 - exit 1 + exit "$INSTALL_EXIT" fi -if ! command -v devcontainer > /dev/null 2>&1; then +if ! DEVCONTAINER_PATH=$(command -v devcontainer 2> /dev/null); then echo "Installation completed but 'devcontainer' command not found in PATH" >&2 exit 1 fi -echo "🥳 @devcontainers/cli has been installed into $(which devcontainer)!" +echo "🥳 @devcontainers/cli has been installed into $DEVCONTAINER_PATH!" exit 0 diff --git a/registry/coder/modules/devcontainers-cli/test-util.ts b/registry/coder/modules/devcontainers-cli/test-util.ts new file mode 100644 index 000000000..ca1911d9f --- /dev/null +++ b/registry/coder/modules/devcontainers-cli/test-util.ts @@ -0,0 +1,104 @@ +import { expect } from "bun:test"; +import { + execContainer, + findResourceInstance, + removeContainer, + runContainer, + writeFileContainer, + type scriptOutput, + type TerraformState, +} from "~test"; + +export const SCRIPT_DATA_DIR = "/tmp/coder-script-data"; +export const SCRIPT_BIN_DIR = `${SCRIPT_DATA_DIR}/bin`; + +type ContainerSetup = (containerID: string) => Promise; + +type AlpineScriptOutput = scriptOutput & { + packageManagerArgs: string[]; +}; + +export const writeExecutable = async ( + containerID: string, + path: string, + content: string, +): Promise => { + await writeFileContainer(containerID, path, content, { user: "root" }); + const result = await execContainer( + containerID, + ["chmod", "755", path], + ["--user", "root"], + ); + expect(result.exitCode).toBe(0); +}; + +export const packageManagerStub = ( + exitCode = 0, + createDevcontainer = true, +): string => `#!/bin/sh +printf '%s\\n' "$@" > /tmp/package-manager-args +${ + createDevcontainer + ? `mkdir -p "$CODER_SCRIPT_BIN_DIR" +printf '#!/bin/sh\\nexit 0\\n' > "$CODER_SCRIPT_BIN_DIR/devcontainer" +chmod +x "$CODER_SCRIPT_BIN_DIR/devcontainer"` + : "" +} +exit ${exitCode} +`; + +export const executeInAlpine = async ( + state: TerraformState, + setup: ContainerSetup, +): Promise => { + const instance = findResourceInstance(state, "coder_script"); + const containerID = await runContainer("alpine"); + + try { + await execContainer(containerID, ["mkdir", "-p", SCRIPT_BIN_DIR]); + await setup(containerID); + + 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", + `PATH=${path}`, + "--env", + `CODER_SCRIPT_BIN_DIR=${SCRIPT_BIN_DIR}`, + ]; + const response = await execContainer( + containerID, + ["sh", "-c", instance.script], + env, + ); + const argsResponse = await execContainer(containerID, [ + "sh", + "-c", + "if [ -f /tmp/package-manager-args ]; then cat /tmp/package-manager-args; fi", + ]); + return { + exitCode: response.exitCode, + stdout: response.stdout.trim() ? response.stdout.trim().split("\n") : [], + stderr: response.stderr.trim() ? response.stderr.trim().split("\n") : [], + packageManagerArgs: argsResponse.stdout.trim() + ? argsResponse.stdout.trim().split("\n") + : [], + }; + } finally { + await removeContainer(containerID); + } +}; + +export const setupPackageManager = async ( + containerID: string, + packageManager: "npm" | "pnpm" | "yarn", + stub = packageManagerStub(), +): Promise => { + await writeExecutable( + containerID, + "/usr/local/bin/docker", + "#!/bin/sh\nexit 0\n", + ); + await writeExecutable(containerID, `/usr/local/bin/${packageManager}`, stub); +};