diff --git a/registry/coder/modules/boundary/README.md b/registry/coder/modules/boundary/README.md new file mode 100644 index 000000000..934eec791 --- /dev/null +++ b/registry/coder/modules/boundary/README.md @@ -0,0 +1,186 @@ +--- +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` +- Writes a default boundary config to `$HOME/.coder-modules/coder/boundary/config/config.yaml` (customizable) +- Automatically adds your Coder deployment domain to the config allowlist +- Exports `BOUNDARY_CONFIG` as a workspace environment variable +- Provides the wrapper path, config path, and script names via outputs + +```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 +} +``` + +## Configuration + +The module ships with a comprehensive default config based on the +[Coder dogfood allowlist](./config.yaml). It covers Anthropic services, +OpenAI services, version control, package managers, container registries, +cloud platforms, and common development tools. + +The Coder deployment domain is automatically added to the allowlist using +`data.coder_workspace.me.access_url`. + +By default the config is written to +`$HOME/.coder-modules/coder/boundary/config/config.yaml` and the +`BOUNDARY_CONFIG` env var points there. You can override it in two ways: + +### Inline config + +Pass the full YAML content directly: + +```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 + + boundary_config = <<-YAML + allowlist: + - domain=your-deployment.coder.com + - domain=api.anthropic.com + - domain=api.openai.com + log_dir: /tmp/boundary_logs + proxy_port: 8087 + log_level: warn + YAML +} +``` + +### External config file + +Point to an existing config file in the workspace. The module will not +write any config and `BOUNDARY_CONFIG` will point to your path: + +```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 + + boundary_config_path = "/workspace/my-boundary-config.yaml" +} +``` + +> **Note:** `boundary_config` and `boundary_config_path` are mutually +> exclusive — setting both produces a validation error. + +See the [Agent Firewall docs](https://coder.com/docs/ai-coder/agent-firewall) +for the full config reference. + +## Usage + +Use the `boundary_wrapper_path` output to access the wrapper path in Terraform +and pass it to scripts that should run commands in network isolation: + +```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 + WRAPPER="${module.boundary[0].boundary_wrapper_path}" + "$WRAPPER" -- 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 + +### With Claude Code + +Use boundary alongside the `claude-code` module to run Claude in a +network-isolated environment. The `coder_app` below waits for both +modules to finish installing before launching Claude behind the boundary +wrapper. + +```tf +module "boundary" { + source = "registry.coder.com/coder/boundary/coder" + version = "0.0.1" + agent_id = coder_agent.main.id +} + +module "claude_code" { + source = "registry.coder.com/coder/claude-code/coder" + version = "5.3.0" + agent_id = coder_agent.main.id +} + +resource "coder_app" "claude_with_boundary" { + agent_id = coder_agent.main.id + slug = "claude-cli" + display_name = "Claude (Boundary)" + command = <<-EOT + # Wait for boundary and claude-code install scripts to complete. + coder exp sync want claude-boundary \ + ${join(" ", module.boundary.scripts)} \ + ${join(" ", module.claude_code.scripts)} > /dev/null 2>&1 + coder exp sync start claude-boundary > /dev/null 2>&1 + + # Run Claude inside the boundary wrapper. + "${module.boundary.boundary_wrapper_path}" \ + --config="${module.boundary.boundary_config_path}" -- claude + EOT +} +``` + +### 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..8fc8bef6a --- /dev/null +++ b/registry/coder/modules/boundary/boundary.tftest.hcl @@ -0,0 +1,179 @@ +# Test for boundary module + +run "plan_with_required_vars" { + command = plan + + variables { + agent_id = "test-agent-id" + } + + # Verify BOUNDARY_CONFIG env var with default config path + assert { + condition = coder_env.boundary_config.name == "BOUNDARY_CONFIG" + error_message = "Environment variable name should be 'BOUNDARY_CONFIG'" + } + + assert { + condition = coder_env.boundary_config.value == "$HOME/.coder-modules/coder/boundary/config/config.yaml" + error_message = "BOUNDARY_CONFIG should default to module_directory/config/config.yaml" + } + + # 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 boundary_config_path output defaults to the managed path + assert { + condition = output.boundary_config_path == "$HOME/.coder-modules/coder/boundary/config/config.yaml" + error_message = "boundary_config_path output should default to managed config path" + } + + # 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 = 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 = 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 = 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 = output.boundary_wrapper_path == "$HOME/.coder-modules/custom/boundary/scripts/boundary-wrapper.sh" + error_message = "boundary_wrapper_path output should use custom module directory" + } + + # Config path should also follow the module directory + assert { + condition = output.boundary_config_path == "$HOME/.coder-modules/custom/boundary/config/config.yaml" + error_message = "boundary_config_path output should use custom module directory" + } +} + +run "plan_with_inline_boundary_config" { + command = plan + + variables { + agent_id = "test-agent-id" + boundary_config = "allowlist:\n - domain=example.com\nlog_level: debug\n" + } + + # BOUNDARY_CONFIG should still point to the managed path since we write + # the inline content there. + assert { + condition = coder_env.boundary_config.value == "$HOME/.coder-modules/coder/boundary/config/config.yaml" + error_message = "BOUNDARY_CONFIG should point to managed config path when using inline config" + } + + assert { + condition = output.boundary_config_path == "$HOME/.coder-modules/coder/boundary/config/config.yaml" + error_message = "boundary_config_path output should point to managed config path" + } +} + +run "plan_with_boundary_config_path" { + command = plan + + variables { + agent_id = "test-agent-id" + boundary_config_path = "/workspace/my-boundary-config.yaml" + } + + # BOUNDARY_CONFIG should point to the user-provided path. + assert { + condition = coder_env.boundary_config.value == "/workspace/my-boundary-config.yaml" + error_message = "BOUNDARY_CONFIG should point to user-provided config path" + } + + assert { + condition = output.boundary_config_path == "/workspace/my-boundary-config.yaml" + error_message = "boundary_config_path output should point to user-provided path" + } +} + +run "plan_with_both_configs_should_fail" { + command = plan + + variables { + agent_id = "test-agent-id" + boundary_config = "allowlist: []" + boundary_config_path = "/workspace/config.yaml" + } + + expect_failures = [ + var.boundary_config, + ] +} diff --git a/registry/coder/modules/boundary/config.yaml.tftpl b/registry/coder/modules/boundary/config.yaml.tftpl new file mode 100644 index 000000000..2bc598bdc --- /dev/null +++ b/registry/coder/modules/boundary/config.yaml.tftpl @@ -0,0 +1,220 @@ +allowlist: + # Your Coder deployment domain (auto-filled from access URL when + # using the default config; replace if customising manually). + - domain=${CODER_DOMAIN} + + # Anthropic Services + - domain=api.anthropic.com + - domain=statsig.anthropic.com + - domain=claude.ai + + # OpenAI Services + - domain=api.openai.com + - domain=platform.openai.com + - domain=openai.com + - domain=chatgpt.com + - domain=*.oaiusercontent.com + - domain=*.oaistatic.com + + # Version Control + - domain=github.com + - domain=www.github.com + - domain=api.github.com + - domain=raw.githubusercontent.com + - domain=objects.githubusercontent.com + - domain=codeload.github.com + - domain=avatars.githubusercontent.com + - domain=camo.githubusercontent.com + - domain=gist.github.com + - domain=gitlab.com + - domain=www.gitlab.com + - domain=registry.gitlab.com + - domain=bitbucket.org + - domain=www.bitbucket.org + - domain=api.bitbucket.org + + # Container Registries + - domain=registry-1.docker.io + - domain=auth.docker.io + - domain=index.docker.io + - domain=hub.docker.com + - domain=www.docker.com + - domain=production.cloudflare.docker.com + - domain=download.docker.com + - domain=*.gcr.io + - domain=ghcr.io + - domain=mcr.microsoft.com + - domain=*.data.mcr.microsoft.com + + # Cloud Platforms + - domain=cloud.google.com + - domain=accounts.google.com + - domain=gcloud.google.com + - domain=*.googleapis.com + - domain=storage.googleapis.com + - domain=compute.googleapis.com + - domain=container.googleapis.com + - domain=azure.com + - domain=portal.azure.com + - domain=microsoft.com + - domain=www.microsoft.com + - domain=*.microsoftonline.com + - domain=packages.microsoft.com + - domain=dotnet.microsoft.com + - domain=dot.net + - domain=visualstudio.com + - domain=dev.azure.com + - domain=oracle.com + - domain=www.oracle.com + - domain=java.com + - domain=www.java.com + - domain=java.net + - domain=www.java.net + - domain=download.oracle.com + - domain=yum.oracle.com + + # Package Managers - JavaScript/Node + - domain=registry.npmjs.org + - domain=www.npmjs.com + - domain=www.npmjs.org + - domain=npmjs.com + - domain=npmjs.org + - domain=yarnpkg.com + - domain=registry.yarnpkg.com + + # Package Managers - Python + - domain=pypi.org + - domain=www.pypi.org + - domain=files.pythonhosted.org + - domain=pythonhosted.org + - domain=test.pypi.org + - domain=pypi.python.org + - domain=pypa.io + - domain=www.pypa.io + + # Package Managers - Ruby + - domain=rubygems.org + - domain=www.rubygems.org + - domain=api.rubygems.org + - domain=index.rubygems.org + - domain=ruby-lang.org + - domain=www.ruby-lang.org + - domain=rubyforge.org + - domain=www.rubyforge.org + - domain=rubyonrails.org + - domain=www.rubyonrails.org + - domain=rvm.io + - domain=get.rvm.io + + # Package Managers - Rust + - domain=crates.io + - domain=www.crates.io + - domain=static.crates.io + - domain=rustup.rs + - domain=static.rust-lang.org + - domain=www.rust-lang.org + + # Package Managers - Go + - domain=proxy.golang.org + - domain=sum.golang.org + - domain=index.golang.org + - domain=golang.org + - domain=www.golang.org + - domain=go.dev + - domain=dl.google.com + - domain=goproxy.io + - domain=pkg.go.dev + + # Package Managers - JVM + - domain=maven.org + - domain=repo.maven.org + - domain=central.maven.org + - domain=repo1.maven.org + - domain=jcenter.bintray.com + - domain=gradle.org + - domain=www.gradle.org + - domain=services.gradle.org + - domain=spring.io + - domain=repo.spring.io + + # Package Managers - Other Languages + - domain=packagist.org + - domain=www.packagist.org + - domain=repo.packagist.org + - domain=nuget.org + - domain=www.nuget.org + - domain=api.nuget.org + - domain=pub.dev + - domain=api.pub.dev + - domain=hex.pm + - domain=www.hex.pm + - domain=cpan.org + - domain=www.cpan.org + - domain=metacpan.org + - domain=www.metacpan.org + - domain=api.metacpan.org + - domain=cocoapods.org + - domain=www.cocoapods.org + - domain=cdn.cocoapods.org + - domain=haskell.org + - domain=www.haskell.org + - domain=hackage.haskell.org + - domain=swift.org + - domain=www.swift.org + + # Linux Distributions + - domain=archive.ubuntu.com + - domain=security.ubuntu.com + - domain=ubuntu.com + - domain=www.ubuntu.com + - domain=*.ubuntu.com + - domain=ppa.launchpad.net + - domain=launchpad.net + - domain=www.launchpad.net + + # Development Tools & Platforms + - domain=dl.k8s.io + - domain=pkgs.k8s.io + - domain=k8s.io + - domain=www.k8s.io + - domain=releases.hashicorp.com + - domain=apt.releases.hashicorp.com + - domain=rpm.releases.hashicorp.com + - domain=archive.releases.hashicorp.com + - domain=hashicorp.com + - domain=www.hashicorp.com + - domain=repo.anaconda.com + - domain=conda.anaconda.org + - domain=anaconda.org + - domain=www.anaconda.com + - domain=anaconda.com + - domain=continuum.io + - domain=apache.org + - domain=www.apache.org + - domain=archive.apache.org + - domain=downloads.apache.org + - domain=eclipse.org + - domain=www.eclipse.org + - domain=download.eclipse.org + - domain=nodejs.org + - domain=www.nodejs.org + + # Cloud Services & Monitoring + - domain=statsig.com + - domain=www.statsig.com + - domain=api.statsig.com + - domain=*.sentry.io + + # Content Delivery & Mirrors + - domain=*.sourceforge.net + - domain=packagecloud.io + - domain=*.packagecloud.io + + # Schema & Configuration + - domain=json-schema.org + - domain=www.json-schema.org + - domain=json.schemastore.org + - domain=www.schemastore.org +log_dir: /tmp/boundary_logs +log_level: warn +proxy_port: 8087 diff --git a/registry/coder/modules/boundary/main.test.ts b/registry/coder/modules/boundary/main.test.ts new file mode 100644 index 000000000..01a37ab1a --- /dev/null +++ b/registry/coder/modules/boundary/main.test.ts @@ -0,0 +1,384 @@ +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 MODULE_DIR = "/home/coder/.coder-modules/coder/boundary"; +const CONFIG_PATH = `${MODULE_DIR}/config/config.yaml`; +const WRAPPER_PATH = `${MODULE_DIR}/scripts/boundary-wrapper.sh`; + +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; + + // BOUNDARY_WRAPPER_PATH env should NOT exist (only output) + const wrapperEnv = resources.find( + (r) => r.type === "coder_env" && r.name === "boundary_wrapper_path", + ); + expect(wrapperEnv).toBeUndefined(); + + // Verify coder_env resource for BOUNDARY_CONFIG + const configEnv = resources.find( + (r) => r.type === "coder_env" && r.name === "boundary_config", + ); + expect(configEnv).toBeDefined(); + expect(configEnv?.instances[0]?.attributes.name).toBe("BOUNDARY_CONFIG"); + expect(configEnv?.instances[0]?.attributes.value).toBe( + "$HOME/.coder-modules/coder/boundary/config/config.yaml", + ); + + // Verify the outputs are set correctly + const coderEnvVars = extractCoderEnvVars(state); + expect(coderEnvVars["BOUNDARY_WRAPPER_PATH"]).toBeUndefined(); + expect(coderEnvVars["BOUNDARY_CONFIG"]).toBe( + "$HOME/.coder-modules/coder/boundary/config/config.yaml", + ); + + // Verify boundary_config_path output + expect(state.outputs["boundary_config_path"]?.value).toBe( + "$HOME/.coder-modules/coder/boundary/config/config.yaml", + ); + + // Verify scripts output contains install script + const scripts = state.outputs["scripts"]?.value as string[]; + expect(scripts).toContain("coder-boundary-install_script"); + }); + + 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, + }); + + // Verify output uses custom dir + const outputs = state.outputs; + expect(outputs["boundary_wrapper_path"]?.value).toBe( + `${customDir}/scripts/boundary-wrapper.sh`, + ); + // Config path follows module directory + expect(outputs["boundary_config_path"]?.value).toBe( + `${customDir}/config/config.yaml`, + ); + }); + + test("terraform-state-inline-config", async () => { + const inlineConfig = + "allowlist:\n - domain=example.com\nlog_level: debug\n"; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-id", + boundary_config: inlineConfig, + }); + + const coderEnvVars = extractCoderEnvVars(state); + // Inline config still writes to the managed path. + expect(coderEnvVars["BOUNDARY_CONFIG"]).toBe( + "$HOME/.coder-modules/coder/boundary/config/config.yaml", + ); + }); + + test("terraform-state-config-path", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-id", + boundary_config_path: "/workspace/my-config.yaml", + }); + + const coderEnvVars = extractCoderEnvVars(state); + // BOUNDARY_CONFIG should point to the user-provided path. + expect(coderEnvVars["BOUNDARY_CONFIG"]).toBe("/workspace/my-config.yaml"); + }); + + test("happy-path-coder-subcommand", async () => { + const { id } = await setup(); + await execModuleScript(id); + + // Verify the wrapper script was created + const wrapperContent = await readFileContainer(id, WRAPPER_PATH); + 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", + WRAPPER_PATH, + ]); + expect(statResult.stdout.trim()).toMatch(/7[0-9][0-9]/); + + // Verify coder-no-caps binary was created + const coderNoCapsResult = await execContainer(id, [ + "test", + "-f", + `${MODULE_DIR}/scripts/coder-no-caps`, + ]); + expect(coderNoCapsResult.exitCode).toBe(0); + + // Verify default boundary config was written inside module directory + const configContent = await readFileContainer(id, CONFIG_PATH); + expect(configContent).toContain("allowlist:"); + expect(configContent).toContain("domain=api.anthropic.com"); + expect(configContent).toContain("domain=api.openai.com"); + expect(configContent).toContain("proxy_port: 8087"); + + // Verify Coder domain was auto-filled from data.coder_workspace.me + // (the placeholder should be replaced with the actual deployment domain). + expect(configContent).not.toContain("domain=your-deployment.coder.com"); + + // Check install log + const installLog = await readFileContainer( + id, + `${MODULE_DIR}/logs/install.log`, + ); + expect(installLog).toContain("Using coder boundary subcommand"); + expect(installLog).toContain("Boundary config written to"); + expect(installLog).toContain("boundary wrapper configured"); + }); + + test("inline-config-written", async () => { + const customConfig = + "allowlist:\n - domain=custom.example.com\nlog_level: info\n"; + const { id } = await setup({ + moduleVariables: { + boundary_config: customConfig, + }, + }); + await execModuleScript(id); + + // Verify the inline config was written + const configContent = await readFileContainer(id, CONFIG_PATH); + expect(configContent).toContain("domain=custom.example.com"); + expect(configContent).toContain("log_level: info"); + }); + + test("config-path-skips-write", async () => { + const { id } = await setup({ + moduleVariables: { + boundary_config_path: "/workspace/external-config.yaml", + }, + }); + await execModuleScript(id); + + // Verify NO config was written to the default path + const checkResult = await execContainer(id, ["test", "-f", CONFIG_PATH]); + expect(checkResult.exitCode).not.toBe(0); + + // Check install log confirms skip + const installLog = await readFileContainer( + id, + `${MODULE_DIR}/logs/install.log`, + ); + expect(installLog).toContain( + "Using external boundary config, skipping config write", + ); + }); + + // 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, + `${MODULE_DIR}/logs/pre_install.log`, + ); + expect(preInstallLog).toContain(preInstallMarker); + + // Verify post-install script ran + const postInstallLog = await readFileContainer( + id, + `${MODULE_DIR}/logs/post_install.log`, + ); + expect(postInstallLog).toContain(postInstallMarker); + + // Verify main install still ran + const installLog = await readFileContainer( + id, + `${MODULE_DIR}/logs/install.log`, + ); + expect(installLog).toContain("boundary wrapper configured"); + }); + + test("env-var-set-correctly", async () => { + const { coderEnvVars } = await setup(); + + // BOUNDARY_WRAPPER_PATH env var should NOT exist + expect(coderEnvVars["BOUNDARY_WRAPPER_PATH"]).toBeUndefined(); + + // Verify BOUNDARY_CONFIG is in the coder env vars + expect(coderEnvVars["BOUNDARY_CONFIG"]).toBe( + "$HOME/.coder-modules/coder/boundary/config/config.yaml", + ); + }); + + 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", + `${WRAPPER_PATH} 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, + `${MODULE_DIR}/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..d5b5495ab --- /dev/null +++ b/registry/coder/modules/boundary/main.tf @@ -0,0 +1,133 @@ +terraform { + required_version = ">= 1.9" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.5" + } + } +} + +data "coder_workspace" "me" {} + +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 "boundary_config" { + type = string + description = "Inline boundary configuration content (YAML). Overrides the module's default config. Mutually exclusive with boundary_config_path." + default = null + + validation { + condition = !(var.boundary_config != null && var.boundary_config_path != null) + error_message = "Only one of boundary_config or boundary_config_path may be set." + } +} + +variable "boundary_config_path" { + type = string + description = "Path to an existing boundary config file in the workspace. When set, no config is written and BOUNDARY_CONFIG points to this path. Mutually exclusive with boundary_config." + default = null +} + +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" + + # Extract domain from the Coder access URL for the default config + # allowlist (e.g., "https://dev.coder.com/" -> "dev.coder.com"). + coder_domain = try(regex("^https?://([^/:]+)", data.coder_workspace.me.access_url)[0], "") + + # Config handling: resolve which config content to write and where + # BOUNDARY_CONFIG points to. + default_boundary_config = templatefile("${path.module}/config.yaml.tftpl", { + CODER_DOMAIN = local.coder_domain + }) + boundary_config_content = var.boundary_config != null ? var.boundary_config : local.default_boundary_config + boundary_config_dir = "${var.module_directory}/config" + boundary_config_file_path = "${local.boundary_config_dir}/config.yaml" + effective_boundary_config_path = var.boundary_config_path != null ? var.boundary_config_path : local.boundary_config_file_path + write_boundary_config = var.boundary_config_path == null + + 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 + WRITE_BOUNDARY_CONFIG = tostring(local.write_boundary_config) + BOUNDARY_CONFIG_CONTENT_B64 = local.write_boundary_config ? base64encode(local.boundary_config_content) : "" + BOUNDARY_CONFIG_DIR = local.boundary_config_dir + BOUNDARY_CONFIG_FILE = local.boundary_config_file_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_config" { + agent_id = var.agent_id + name = "BOUNDARY_CONFIG" + value = local.effective_boundary_config_path +} + +output "boundary_wrapper_path" { + description = "Path to the boundary wrapper script." + value = local.boundary_wrapper_path +} + +output "boundary_config_path" { + description = "Effective path to the boundary config file." + value = local.effective_boundary_config_path +} + +output "scripts" { + description = "List of script names for coder exp sync coordination." + 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..6523d85aa --- /dev/null +++ b/registry/coder/modules/boundary/scripts/install.sh.tftpl @@ -0,0 +1,132 @@ +#!/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}" +WRITE_BOUNDARY_CONFIG='${WRITE_BOUNDARY_CONFIG}' +BOUNDARY_CONFIG_CONTENT=$(echo -n '${BOUNDARY_CONFIG_CONTENT_B64}' | base64 -d) +BOUNDARY_CONFIG_DIR="${BOUNDARY_CONFIG_DIR}" +BOUNDARY_CONFIG_FILE="${BOUNDARY_CONFIG_FILE}" + +printf "BOUNDARY_VERSION: %s\n" "$${BOUNDARY_VERSION}" +printf "COMPILE_BOUNDARY_FROM_SOURCE: %s\n" "$${COMPILE_BOUNDARY_FROM_SOURCE}" +printf "USE_BOUNDARY_DIRECTLY: %s\n" "$${USE_BOUNDARY_DIRECTLY}" +printf "MODULE_DIR: %s\n" "$${MODULE_DIR}" +printf "BOUNDARY_WRAPPER_PATH: %s\n" "$${BOUNDARY_WRAPPER_PATH}" +printf "WRITE_BOUNDARY_CONFIG: %s\n" "$${WRITE_BOUNDARY_CONFIG}" +printf "BOUNDARY_CONFIG_DIR: %s\n" "$${BOUNDARY_CONFIG_DIR}" +printf "BOUNDARY_CONFIG_FILE: %s\n" "$${BOUNDARY_CONFIG_FILE}" + +validate_boundary_subcommand() { + if ! command -v coder > /dev/null 2>&1; then + echo "Error: 'coder' command not found. boundary cannot be enabled." >&2 + exit 1 + fi + + local output + echo "Checking for license" + if ! output=$(coder boundary 2>&1); then + if echo "$${output}" | grep -qi "license is not entitled"; then + echo "Error: your Coder deployment is not licensed for the boundary feature." >&2 + echo "$${output}" >&2 + echo "" >&2 + exit 1 + fi + 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 +} + +# Write boundary config file if the module is responsible for it. +write_boundary_config() { + if [[ "$${WRITE_BOUNDARY_CONFIG}" != "true" ]]; then + echo "Using external boundary config, skipping config write." + return 0 + fi + + mkdir -p "$${BOUNDARY_CONFIG_DIR}" + echo "$${BOUNDARY_CONFIG_CONTENT}" > "$${BOUNDARY_CONFIG_FILE}" + echo "Boundary config written to $${BOUNDARY_CONFIG_FILE}" +} + +# Set up boundary: install, write config, create wrapper script. +setup_boundary() { + echo "Setting up coder boundary..." + + # Install boundary binary if needed + install_boundary + + # Write boundary config + write_boundary_config + + # 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