diff --git a/registry/coder/modules/boundary/README.md b/registry/coder/modules/boundary/README.md new file mode 100644 index 000000000..85629e9ad --- /dev/null +++ b/registry/coder/modules/boundary/README.md @@ -0,0 +1,98 @@ +--- +display_name: Boundary +description: Configures boundary for network isolation in Coder workspaces +icon: ../../../../.icons/coder.svg +verified: true +tags: [boundary, ai, agents, firewall] +--- + +# Boundary + +Installs [boundary](https://coder.com/docs/ai-coder/agent-firewall) for network isolation in Coder workspaces. + +This module: + +- Installs boundary (via coder subcommand, direct installation, or compilation from source) +- Creates a wrapper script at `$HOME/.coder-modules/coder/boundary/scripts/boundary-wrapper.sh` +- Exports `BOUNDARY_WRAPPER_PATH` as a workspace environment variable +- Provides the wrapper path via the `boundary_wrapper_path` output + +```tf +module "boundary" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/boundary/coder" + version = "0.0.1" + agent_id = coder_agent.main.id +} +``` + +## Usage + +The `BOUNDARY_WRAPPER_PATH` environment variable is automatically available to all +workspace processes. Start scripts should check for this variable and use it to prefix +commands that should run in network isolation: + +```bash +if [ -n "${BOUNDARY_WRAPPER_PATH:-}" ]; then + # Run command with boundary wrapper + "${BOUNDARY_WRAPPER_PATH}" --config=~/.coder-modules/coder/boundary/config.yaml --log-level=info -- my-command --args +fi +``` + +Alternatively, you can use the module output to access the wrapper path in Terraform: + +```tf +module "boundary" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/boundary/coder" + version = "0.0.1" + agent_id = coder_agent.main.id +} + +resource "coder_script" "my_app" { + agent_id = coder_agent.main.id + script = <<-EOT + # Access the boundary wrapper path + WRAPPER="${module.boundary[0].boundary_wrapper_path}" + "$WRAPPER" --config=~/.coder-modules/coder/boundary/config.yaml -- my-command --args + EOT +} +``` + +### Script Synchronization + +The `scripts` output provides a list of script names that can be used with `coder exp sync` to coordinate script execution. This is useful when your scripts need to wait for boundary installation to complete before running. + +The list may contain the following script names: + +- `coder-boundary-pre_install_script` - Pre-installation script (if configured) +- `coder-boundary-install_script` - Main boundary installation script +- `coder-boundary-post_install_script` - Post-installation script (if configured) + +## Examples + +### Compile from source + +```tf +module "boundary" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/boundary/coder" + version = "0.0.1" + agent_id = coder_agent.main.id + compile_boundary_from_source = true + boundary_version = "main" +} +``` + +### Use release binary + +```tf +module "boundary" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/boundary/coder" + version = "0.0.1" + agent_id = coder_agent.main.id + use_boundary_directly = true + boundary_version = "latest" +} +``` diff --git a/registry/coder/modules/boundary/boundary.tftest.hcl b/registry/coder/modules/boundary/boundary.tftest.hcl new file mode 100644 index 000000000..91ee4a089 --- /dev/null +++ b/registry/coder/modules/boundary/boundary.tftest.hcl @@ -0,0 +1,137 @@ +# Test for boundary module + +run "plan_with_required_vars" { + command = plan + + variables { + agent_id = "test-agent-id" + } + + # Verify the coder_env resource is created with correct agent_id + assert { + condition = coder_env.boundary_wrapper_path.agent_id == "test-agent-id" + error_message = "boundary_wrapper_path agent_id should match the input variable" + } + + assert { + condition = coder_env.boundary_wrapper_path.name == "BOUNDARY_WRAPPER_PATH" + error_message = "Environment variable name should be 'BOUNDARY_WRAPPER_PATH'" + } + + assert { + condition = coder_env.boundary_wrapper_path.value == "$HOME/.coder-modules/coder/boundary/scripts/boundary-wrapper.sh" + error_message = "Environment variable value should be the boundary wrapper path" + } + + # Verify the boundary_wrapper_path output + assert { + condition = output.boundary_wrapper_path == "$HOME/.coder-modules/coder/boundary/scripts/boundary-wrapper.sh" + error_message = "boundary_wrapper_path output should be correct" + } + + # Verify the scripts output contains the install script name + assert { + condition = contains(output.scripts, "coder-boundary-install_script") + error_message = "scripts should contain the install script name" + } +} + +run "plan_with_compile_from_source" { + command = plan + + variables { + agent_id = "test-agent-id" + compile_boundary_from_source = true + boundary_version = "main" + } + + assert { + condition = coder_env.boundary_wrapper_path.agent_id == "test-agent-id" + error_message = "boundary_wrapper_path agent_id should match the input variable" + } + + assert { + condition = output.boundary_wrapper_path == "$HOME/.coder-modules/coder/boundary/scripts/boundary-wrapper.sh" + error_message = "boundary_wrapper_path output should be correct" + } + + assert { + condition = contains(output.scripts, "coder-boundary-install_script") + error_message = "scripts should contain the install script name" + } +} + +run "plan_with_use_directly" { + command = plan + + variables { + agent_id = "test-agent-id" + use_boundary_directly = true + boundary_version = "latest" + } + + assert { + condition = coder_env.boundary_wrapper_path.agent_id == "test-agent-id" + error_message = "boundary_wrapper_path agent_id should match the input variable" + } + + assert { + condition = output.boundary_wrapper_path == "$HOME/.coder-modules/coder/boundary/scripts/boundary-wrapper.sh" + error_message = "boundary_wrapper_path output should be correct" + } + + assert { + condition = contains(output.scripts, "coder-boundary-install_script") + error_message = "scripts should contain the install script name" + } +} + +run "plan_with_custom_hooks" { + command = plan + + variables { + agent_id = "test-agent-id" + pre_install_script = "echo 'Before install'" + post_install_script = "echo 'After install'" + } + + assert { + condition = coder_env.boundary_wrapper_path.agent_id == "test-agent-id" + error_message = "boundary_wrapper_path agent_id should match the input variable" + } + + assert { + condition = contains(output.scripts, "coder-boundary-install_script") + error_message = "scripts should contain the install script name" + } + + # Verify pre and post install script names are set + assert { + condition = contains(output.scripts, "coder-boundary-pre_install_script") + error_message = "scripts should contain the pre_install script name" + } + + assert { + condition = contains(output.scripts, "coder-boundary-post_install_script") + error_message = "scripts should contain the post_install script name" + } +} + +run "plan_with_custom_module_directory" { + command = plan + + variables { + agent_id = "test-agent-id" + module_directory = "$HOME/.coder-modules/custom/boundary" + } + + assert { + condition = coder_env.boundary_wrapper_path.value == "$HOME/.coder-modules/custom/boundary/scripts/boundary-wrapper.sh" + error_message = "Environment variable should use custom module directory" + } + + assert { + condition = output.boundary_wrapper_path == "$HOME/.coder-modules/custom/boundary/scripts/boundary-wrapper.sh" + error_message = "boundary_wrapper_path output should use custom module directory" + } +} diff --git a/registry/coder/modules/boundary/main.test.ts b/registry/coder/modules/boundary/main.test.ts new file mode 100644 index 000000000..cadd9a84a --- /dev/null +++ b/registry/coder/modules/boundary/main.test.ts @@ -0,0 +1,285 @@ +import { + test, + afterEach, + describe, + setDefaultTimeout, + beforeAll, + expect, +} from "bun:test"; +import { + execContainer, + readFileContainer, + runTerraformInit, + runTerraformApply, + testRequiredVariables, + runContainer, + removeContainer, +} from "~test"; +import { + loadTestFile, + writeExecutable, + execModuleScript, + extractCoderEnvVars, +} from "../agentapi/test-util"; + +let cleanupFunctions: (() => Promise)[] = []; +const registerCleanup = (cleanup: () => Promise) => { + cleanupFunctions.push(cleanup); +}; +afterEach(async () => { + const cleanupFnsCopy = cleanupFunctions.slice().reverse(); + cleanupFunctions = []; + for (const cleanup of cleanupFnsCopy) { + try { + await cleanup(); + } catch (error) { + console.error("Error during cleanup:", error); + } + } +}); + +interface SetupProps { + moduleVariables?: Record; + skipCoderMock?: boolean; +} + +const setup = async ( + props?: SetupProps, +): Promise<{ id: string; coderEnvVars: Record }> => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + ...props?.moduleVariables, + }); + + const coderEnvVars = extractCoderEnvVars(state); + const id = await runContainer("codercom/enterprise-node:latest"); + registerCleanup(async () => { + await removeContainer(id); + }); + + await execContainer(id, ["bash", "-c", "mkdir -p /home/coder/project"]); + + // Create a mock coder binary with boundary subcommand and exp sync support + if (!props?.skipCoderMock) { + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/coder", + content: await loadTestFile(import.meta.dir, "coder-mock.sh"), + }); + } + + // Extract ALL coder_scripts from the state (coder-utils creates multiple) + const allScripts = state.resources + .filter((r) => r.type === "coder_script") + .map((r) => ({ + name: r.name, + script: r.instances[0].attributes.script as string, + })); + + // Run scripts in lifecycle order + const executionOrder = [ + "pre_install_script", + "install_script", + "post_install_script", + ]; + const orderedScripts = executionOrder + .map((name) => allScripts.find((s) => s.name === name)) + .filter((s): s is NonNullable => s != null); + + // Write each script individually and create a combined runner + const scriptPaths: string[] = []; + for (const s of orderedScripts) { + const scriptPath = `/home/coder/${s.name}.sh`; + await writeExecutable({ + containerId: id, + filePath: scriptPath, + content: s.script, + }); + scriptPaths.push(scriptPath); + } + + const combinedScript = [ + "#!/bin/bash", + "set -o errexit", + "set -o pipefail", + ...scriptPaths.map((p) => `bash "${p}"`), + ].join("\n"); + + await writeExecutable({ + containerId: id, + filePath: "/home/coder/script.sh", + content: combinedScript, + }); + + return { id, coderEnvVars }; +}; + +setDefaultTimeout(60 * 1000); + +describe("boundary", async () => { + beforeAll(async () => { + await runTerraformInit(import.meta.dir); + }); + + testRequiredVariables(import.meta.dir, { + agent_id: "test-agent-id", + }); + + test("terraform-state-basic", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-id", + }); + + const resources = state.resources; + + // Verify coder_env resource for BOUNDARY_WRAPPER_PATH + const boundaryEnv = resources.find( + (r) => r.type === "coder_env" && r.name === "boundary_wrapper_path", + ); + expect(boundaryEnv).toBeDefined(); + expect(boundaryEnv?.instances[0]?.attributes.name).toBe( + "BOUNDARY_WRAPPER_PATH", + ); + expect(boundaryEnv?.instances[0]?.attributes.value).toBe( + "$HOME/.coder-modules/coder/boundary/scripts/boundary-wrapper.sh", + ); + + // Verify the outputs are set correctly + const coderEnvVars = extractCoderEnvVars(state); + expect(coderEnvVars["BOUNDARY_WRAPPER_PATH"]).toBe( + "$HOME/.coder-modules/coder/boundary/scripts/boundary-wrapper.sh", + ); + }); + + test("terraform-state-custom-module-directory", async () => { + const customDir = "$HOME/.coder-modules/custom/boundary"; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-id", + module_directory: customDir, + }); + + const coderEnvVars = extractCoderEnvVars(state); + expect(coderEnvVars["BOUNDARY_WRAPPER_PATH"]).toBe( + `${customDir}/scripts/boundary-wrapper.sh`, + ); + }); + + test("happy-path-coder-subcommand", async () => { + const { id } = await setup(); + await execModuleScript(id); + + // Verify the wrapper script was created + const wrapperContent = await readFileContainer( + id, + "/home/coder/.coder-modules/coder/boundary/scripts/boundary-wrapper.sh", + ); + expect(wrapperContent).toContain("#!/usr/bin/env bash"); + expect(wrapperContent).toContain("coder-no-caps"); + expect(wrapperContent).toContain("boundary"); + + // Verify the wrapper script is executable + const statResult = await execContainer(id, [ + "stat", + "-c", + "%a", + "/home/coder/.coder-modules/coder/boundary/scripts/boundary-wrapper.sh", + ]); + expect(statResult.stdout.trim()).toMatch(/7[0-9][0-9]/); // Should be executable (7xx) + + // Verify coder-no-caps binary was created + const coderNoCapsResult = await execContainer(id, [ + "test", + "-f", + "/home/coder/.coder-modules/coder/boundary/scripts/coder-no-caps", + ]); + expect(coderNoCapsResult.exitCode).toBe(0); + + // Check install log + const installLog = await readFileContainer( + id, + "/home/coder/.coder-modules/coder/boundary/logs/install.log", + ); + expect(installLog).toContain("Using coder boundary subcommand"); + expect(installLog).toContain("boundary wrapper configured"); + }); + + // Note: Tests for use_boundary_directly and compile_from_source are skipped + // because they require network access (downloading boundary) or compilation + // which are too slow for unit tests. These modes are tested manually. + + test("custom-hooks", async () => { + const preInstallMarker = "pre-install-executed"; + const postInstallMarker = "post-install-executed"; + + const { id } = await setup({ + moduleVariables: { + pre_install_script: `#!/bin/bash\necho '${preInstallMarker}'`, + post_install_script: `#!/bin/bash\necho '${postInstallMarker}'`, + }, + }); + await execModuleScript(id); + + // Verify pre-install script ran + const preInstallLog = await readFileContainer( + id, + "/home/coder/.coder-modules/coder/boundary/logs/pre_install.log", + ); + expect(preInstallLog).toContain(preInstallMarker); + + // Verify post-install script ran + const postInstallLog = await readFileContainer( + id, + "/home/coder/.coder-modules/coder/boundary/logs/post_install.log", + ); + expect(postInstallLog).toContain(postInstallMarker); + + // Verify main install still ran + const installLog = await readFileContainer( + id, + "/home/coder/.coder-modules/coder/boundary/logs/install.log", + ); + expect(installLog).toContain("boundary wrapper configured"); + }); + + test("env-var-set-correctly", async () => { + const { id, coderEnvVars } = await setup(); + + // Verify BOUNDARY_WRAPPER_PATH is in the coder env vars + expect(coderEnvVars["BOUNDARY_WRAPPER_PATH"]).toBe( + "$HOME/.coder-modules/coder/boundary/scripts/boundary-wrapper.sh", + ); + }); + + test("wrapper-script-execution", async () => { + const { id } = await setup(); + await execModuleScript(id); + + // Try executing the wrapper script with a command + const wrapperResult = await execContainer(id, [ + "bash", + "-c", + "/home/coder/.coder-modules/coder/boundary/scripts/boundary-wrapper.sh echo boundary-test", + ]); + + // The wrapper passes the command directly to the boundary command + expect(wrapperResult.stdout).toContain("boundary-test"); + }); + + test("installation-idempotency", async () => { + const { id } = await setup(); + + // Run the installation twice + await execModuleScript(id); + const firstInstallLog = await readFileContainer( + id, + "/home/coder/.coder-modules/coder/boundary/logs/install.log", + ); + + // Run again + const secondRun = await execModuleScript(id); + expect(secondRun.exitCode).toBe(0); + + // Both runs should succeed + expect(firstInstallLog).toContain("boundary wrapper configured"); + }); +}); diff --git a/registry/coder/modules/boundary/main.tf b/registry/coder/modules/boundary/main.tf new file mode 100644 index 000000000..18ec0814e --- /dev/null +++ b/registry/coder/modules/boundary/main.tf @@ -0,0 +1,89 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.5" + } + } +} + +# Add required variables for your modules and remove any unneeded variables +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "boundary_version" { + type = string + description = "Boundary version. When use_boundary_directly is true, a release version should be provided or 'latest' for the latest release. When compile_boundary_from_source is true, a valid git reference should be provided (tag, commit, branch)." + default = "latest" +} + +variable "compile_boundary_from_source" { + type = bool + description = "Whether to compile boundary from source instead of using the official install script." + default = false +} + +variable "use_boundary_directly" { + type = bool + description = "Whether to use boundary binary directly instead of `coder boundary` subcommand. When false (default), uses `coder boundary` subcommand. When true, installs and uses boundary binary from release." + default = false +} + +variable "pre_install_script" { + type = string + description = "Custom script to run before installing Boundary." + default = null +} + +variable "post_install_script" { + type = string + description = "Custom script to run after installing Boundary." + default = null +} + +variable "module_directory" { + type = string + description = "Directory where the boundary module scripts will be located. Default is $HOME/.coder-modules/coder/boundary." + default = "$HOME/.coder-modules/coder/boundary" +} + +locals { + boundary_wrapper_path = "${var.module_directory}/scripts/boundary-wrapper.sh" + install_script = templatefile("${path.module}/scripts/install.sh.tftpl", { + BOUNDARY_VERSION = var.boundary_version + COMPILE_BOUNDARY_FROM_SOURCE = tostring(var.compile_boundary_from_source) + USE_BOUNDARY_DIRECTLY = tostring(var.use_boundary_directly) + MODULE_DIR = var.module_directory + BOUNDARY_WRAPPER_PATH = local.boundary_wrapper_path + }) +} + +module "coder_utils" { + source = "registry.coder.com/coder/coder-utils/coder" + version = "0.0.1" + agent_id = var.agent_id + display_name_prefix = "Boundary" + module_directory = var.module_directory + pre_install_script = var.pre_install_script + post_install_script = var.post_install_script + install_script = local.install_script +} + +resource "coder_env" "boundary_wrapper_path" { + agent_id = var.agent_id + name = "BOUNDARY_WRAPPER_PATH" + value = local.boundary_wrapper_path +} + +output "boundary_wrapper_path" { + description = "Path to the boundary wrapper script." + value = local.boundary_wrapper_path +} + +output "scripts" { + value = module.coder_utils.scripts +} diff --git a/registry/coder/modules/boundary/scripts/install.sh.tftpl b/registry/coder/modules/boundary/scripts/install.sh.tftpl new file mode 100644 index 000000000..60702a5e3 --- /dev/null +++ b/registry/coder/modules/boundary/scripts/install.sh.tftpl @@ -0,0 +1,100 @@ +#!/bin/bash +# Sets up boundary for network isolation in Coder workspaces. + +set -o errexit +set -o pipefail + +BOUNDARY_VERSION='${BOUNDARY_VERSION}' +COMPILE_BOUNDARY_FROM_SOURCE='${COMPILE_BOUNDARY_FROM_SOURCE}' +USE_BOUNDARY_DIRECTLY='${USE_BOUNDARY_DIRECTLY}' +MODULE_DIR="${MODULE_DIR}" +BOUNDARY_WRAPPER_PATH="${BOUNDARY_WRAPPER_PATH}" + +validate_boundary_subcommand() { + if command -v coder > /dev/null 2>&1; then + if coder boundary --help > /dev/null 2>&1; then + return 0 + else + echo "Error: 'coder' command found but does not support 'boundary' subcommand. Set use_boundary_directly=true or compile_boundary_from_source=true." >&2 + exit 1 + fi + else + echo "Error: 'coder' command not found. boundary cannot be enabled." >&2 + exit 1 + fi +} + +# Install boundary binary if needed. +# Uses one of three strategies: +# 1. Compile from source (compile_boundary_from_source=true) +# 2. Install from release (use_boundary_directly=true) +# 3. Use coder boundary subcommand (default, no installation needed) +install_boundary() { + if [[ "$${COMPILE_BOUNDARY_FROM_SOURCE}" = "true" ]]; then + echo "Compiling boundary from source (version: $${BOUNDARY_VERSION})" + + # Remove existing boundary directory to allow re-running safely + if [[ -d boundary ]]; then + rm -rf boundary + fi + + echo "Cloning boundary repository" + git clone https://github.com/coder/boundary.git + cd boundary || exit 1 + git checkout "$${BOUNDARY_VERSION}" + + make build + + sudo cp boundary /usr/local/bin/ + sudo chmod +x /usr/local/bin/boundary + cd - || exit 1 + elif [[ "$${USE_BOUNDARY_DIRECTLY}" = "true" ]]; then + echo "Installing boundary using official install script (version: $${BOUNDARY_VERSION})" + curl -fsSL https://raw.githubusercontent.com/coder/boundary/main/install.sh | bash -s -- --version "$${BOUNDARY_VERSION}" + else + validate_boundary_subcommand + echo "Using coder boundary subcommand (provided by Coder)" + fi +} + +# Set up boundary: install, create wrapper script. +setup_boundary() { + echo "Setting up coder boundary..." + + # Install boundary binary if needed + install_boundary + + # Ensure the wrapper script directory exists. + mkdir -p "$(dirname "$${BOUNDARY_WRAPPER_PATH}")" + + if [[ "$${COMPILE_BOUNDARY_FROM_SOURCE}" = "true" ]] || [[ "$${USE_BOUNDARY_DIRECTLY}" = "true" ]]; then + # Use boundary binary directly (from compilation or release installation) + cat > "$${BOUNDARY_WRAPPER_PATH}" << 'WRAPPER_EOF' +#!/usr/bin/env bash +set -euo pipefail +exec boundary "$@" +WRAPPER_EOF + else + # Use coder boundary subcommand (default) + # Copy coder binary to strip CAP_NET_ADMIN capabilities. + # This is necessary because boundary doesn't work with privileged binaries + # (you can't launch privileged binaries inside network namespaces unless + # you have sys_admin). + CODER_NO_CAPS="$${MODULE_DIR}/scripts/coder-no-caps" + if ! cp "$(command -v coder)" "$${CODER_NO_CAPS}"; then + echo "Error: Failed to copy coder binary to $${CODER_NO_CAPS}. boundary cannot be enabled." >&2 + exit 1 + fi + cat > "$${BOUNDARY_WRAPPER_PATH}" << 'WRAPPER_EOF' +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$${BASH_SOURCE[0]}")" && pwd)" +exec "$${SCRIPT_DIR}/coder-no-caps" boundary "$@" +WRAPPER_EOF + fi + + chmod +x "$${BOUNDARY_WRAPPER_PATH}" + echo "boundary wrapper configured: $${BOUNDARY_WRAPPER_PATH}" +} + +setup_boundary diff --git a/registry/coder/modules/boundary/testdata/coder-mock.sh b/registry/coder/modules/boundary/testdata/coder-mock.sh new file mode 100644 index 000000000..892420047 --- /dev/null +++ b/registry/coder/modules/boundary/testdata/coder-mock.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +# Mock coder command for testing boundary module +# Handles: coder boundary [--help | ] +# Handles: coder exp sync [want|start|complete] (no-op for testing) + +# Handle exp sync commands (no-op for testing) +if [[ "$1" == "exp" ]] && [[ "$2" == "sync" ]]; then + exit 0 +fi + +if [[ "$1" == "boundary" ]]; then + shift + + # Handle --help flag + if [[ "$1" == "--help" ]]; then + cat << 'EOF' +boundary - Run commands in network isolation + +Usage: + coder boundary [flags] -- [args...] + +Examples: + coder boundary -- curl https://example.com + coder boundary -- npm install + +Flags: + -h, --help help for boundary +EOF + exit 0 + fi + + # Execute the remaining arguments as a command + exec "$@" +fi + +echo "Mock coder: Unknown command: $*" +exit 1