mirror of
https://github.com/coder/registry.git
synced 2026-06-03 04:58:15 +00:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28a4d4a4e3 | |||
| b8ae549102 | |||
| fafe8cca7b | |||
| 4517a0c4e0 | |||
| 3d778472e7 | |||
| 36701a3538 | |||
| 0acbcb648e | |||
| b9d352e1ad | |||
| 4d1814a191 | |||
| 968c1c1211 | |||
| b9f9fac9ee | |||
| ad25115a92 | |||
| c724684589 | |||
| b76b544e78 | |||
| d3885a5047 | |||
| de7bd01021 | |||
| 494ad9bd48 | |||
| 5ee68d04d1 | |||
| 516a934694 | |||
| 344b02e4ab | |||
| 31a07ac823 | |||
| 5973739f41 | |||
| ad61bddfb2 | |||
| eea5b24e3d | |||
| ee035ee9b9 | |||
| 5bc668aa4d | |||
| caaff0c1e9 | |||
| 057d7396ea | |||
| fc66478b94 | |||
| 19f6dc947f | |||
| 962cd16efd | |||
| 8c130bcb5a | |||
| 516b9ce4ae | |||
| da8e296b1c |
@@ -0,0 +1,369 @@
|
||||
---
|
||||
name: coder-modules
|
||||
description: Creates and updates Coder Registry modules with proper scaffolding, Terraform testing, README frontmatter, and version management
|
||||
---
|
||||
|
||||
# Coder Modules
|
||||
|
||||
Coder Registry modules are reusable Terraform components that live under `registry/<namespace>/modules/<name>/` and are consumed by templates via `module` blocks.
|
||||
|
||||
## Before You Start
|
||||
|
||||
Before writing or modifying any code:
|
||||
|
||||
1. **Understand the request.** What tool, integration, or functionality is the module providing? What Coder resources does it need (`coder_script`, `coder_app`, `coder_env`, etc.)? Read the official documentation for the target tool or integration (installation steps, CLI flags, config files, environment variables, ports) so you can implement the module properly without guessing.
|
||||
2. **Research existing modules.** Search the registry for similar modules. Read their `main.tf` to understand patterns, variable conventions, and how they solve similar problems. Avoid duplicating existing functionality.
|
||||
3. **Check the Coder provider docs.** Verify that the resources and attributes you plan to use exist in the provider version you're targeting. Use the version-specific docs URL if needed.
|
||||
4. **Clarify before building.** If the request is ambiguous (e.g. unclear which Coder resource to use, whether a `coder_app` vs `coder_script` is appropriate, what variables to expose, or which namespace to use), ask for clarification rather than guessing. Never assume a namespace; always confirm with the user.
|
||||
5. **Plan the structure.** Decide on script organization (root `run.sh`, `scripts/` directory, or inline), what variables to expose, and what tests to write.
|
||||
|
||||
Always prefer the proper implementation over a simpler shortcut. Modules are infrastructure that users depend on. Doing less work is not the same as reducing complexity if it leaves the module incomplete or fragile.
|
||||
|
||||
## Documentation References
|
||||
|
||||
### Coder
|
||||
|
||||
- Coder docs (latest): <https://coder.com/docs>
|
||||
- Version-specific Coder docs: `https://coder.com/docs/@v{MAJOR}.{MINOR}.{PATCH}` (e.g. <https://coder.com/docs/@v2.31.5>)
|
||||
- Coder Registry: <https://registry.coder.com>
|
||||
|
||||
### Coder Terraform provider
|
||||
|
||||
- Provider docs (latest): <https://registry.terraform.io/providers/coder/coder/latest/docs>
|
||||
- Version-specific provider docs: replace `latest` with a version number (e.g. <https://registry.terraform.io/providers/coder/coder/2.13.1/docs>)
|
||||
|
||||
Resources:
|
||||
|
||||
| Resource | Docs |
|
||||
| ---------------- | ------------------------------------------------------------------------------------ |
|
||||
| `coder_app` | <https://registry.terraform.io/providers/coder/coder/latest/docs/resources/app> |
|
||||
| `coder_script` | <https://registry.terraform.io/providers/coder/coder/latest/docs/resources/script> |
|
||||
| `coder_env` | <https://registry.terraform.io/providers/coder/coder/latest/docs/resources/env> |
|
||||
| `coder_metadata` | <https://registry.terraform.io/providers/coder/coder/latest/docs/resources/metadata> |
|
||||
|
||||
Data sources:
|
||||
|
||||
| Data Source | Docs |
|
||||
| ----------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| `coder_parameter` | <https://registry.terraform.io/providers/coder/coder/latest/docs/data-sources/parameter> |
|
||||
| `coder_workspace` | <https://registry.terraform.io/providers/coder/coder/latest/docs/data-sources/workspace> |
|
||||
| `coder_workspace_owner` | <https://registry.terraform.io/providers/coder/coder/latest/docs/data-sources/workspace_owner> |
|
||||
|
||||
## Scaffolding a New Module
|
||||
|
||||
Only use this when creating a brand new module that does not yet exist. When updating an existing module, edit its files directly.
|
||||
|
||||
From repo root:
|
||||
|
||||
```bash
|
||||
./scripts/new_module.sh namespace/module-name
|
||||
```
|
||||
|
||||
Names must be lowercase alphanumeric with hyphens (e.g. `coder/my-tool`). Underscores are not allowed.
|
||||
|
||||
Creates `registry/<namespace>/modules/<module-name>/` with:
|
||||
|
||||
- `main.tf`: Terraform config with common resource patterns and variables — read this as the primary reference for module structure
|
||||
- `README.md`: frontmatter and usage examples
|
||||
- `MODULE_NAME.tftest.hcl`: Terraform native tests
|
||||
- `run.sh`: install/start-up script template
|
||||
|
||||
If the namespace is new, the script also creates `registry/<namespace>/` with a README. New namespaces additionally need:
|
||||
|
||||
- `registry/<namespace>/.images/avatar.svg` (or `.png`): square image, 400x400px minimum
|
||||
- The namespace README `avatar` field pointing to `./.images/avatar.svg`
|
||||
|
||||
The scaffolding script does not create the `.images/` directory or avatar file. When a new namespace is created, create `registry/<namespace>/.images/` and add a placeholder `avatar.svg` so the directory structure is ready for the user to replace with their real avatar.
|
||||
|
||||
The generated namespace README contains placeholder fields (`display_name`, `bio`, `status`, `github`, `avatar`, etc.) that the user must fill out. The `status` field is required and must be `official`, `partner`, or `community` (typically `community` for new contributors).
|
||||
|
||||
## Key Patterns
|
||||
|
||||
- Provider version constraints must reflect actual functionality requirements. Only raise the minimum `coder` provider version (e.g. `>= 2.5` to `>= 2.8`) when the module uses a resource, attribute, or behavior introduced in that version; check the provider changelog to confirm.
|
||||
- Variable names MUST be `snake_case` (no hyphens; validation rejects them)
|
||||
- New variables must have sensible defaults for backward compatibility
|
||||
- Common variable: `agent_id` (string, required, no default)
|
||||
- Common variable: `order` (number, default `null`, controls UI position)
|
||||
- Use `locals {}` for computed values: URL normalization, base64 encoding, `file()` script content, config assembly
|
||||
- Modules can consume other registry modules via `module` blocks (e.g. `cursor` uses `vscode-desktop-core`, CLI wrappers use `agentapi`). Before consuming a module, read its `main.tf` and `README.md` to understand the full interface: accepted variables, outputs, prerequisites, and runtime requirements. If you are inside the registry repo, read these files directly. Otherwise, read the module's page at `https://registry.coder.com/modules/<namespace>/<module-name>` which includes the full source, README, and variable definitions. Never pass arguments without confirming they exist.
|
||||
- Most modules expose configuration via `variable` blocks, letting the template pass values. Use `coder_parameter` inside a module only when the module needs to present a UI choice directly to the workspace user (e.g. region selectors, IDE pickers).
|
||||
- For parameter-only modules (region selectors, etc.), use `dynamic "option"` with `for_each` from a `locals` map and expose an `output` for the selected value.
|
||||
- `coder_script` icons use the `/icon/<name>.svg` format. The `display_name` is typically the product name (e.g. "code-server", "Git Clone", "File Browser").
|
||||
- Do not add comments that narrate what the code does or label sections. Only comment when explaining something non-obvious (e.g. why a workaround exists, a subtle constraint, or an unusual design choice).
|
||||
|
||||
## README.md
|
||||
|
||||
Required YAML frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
display_name: My Tool
|
||||
description: Short description of what this module does
|
||||
icon: ../../../../.icons/tool.svg
|
||||
verified: false
|
||||
tags: [helper, ide]
|
||||
---
|
||||
```
|
||||
|
||||
Content rules:
|
||||
|
||||
- Single H1 heading matching `display_name`, directly below frontmatter
|
||||
- When increasing header levels, increment by one each time (h1 -> h2 -> h3, not h1 -> h3)
|
||||
- Usage snippet with `registry.coder.com/<ns>/<module>/coder` and pinned `version`
|
||||
- Code fences labeled `tf` (NOT `hcl`)
|
||||
- Relative icon paths (e.g. `../../../../.icons/`)
|
||||
- **Do NOT include tables or lists that enumerate variables, parameters, or outputs.** The registry generates variable and output documentation automatically from the Terraform source. Describe what the module does and how to use it in prose, not by listing every configurable field.
|
||||
- Usage examples are encouraged
|
||||
- Use [GFM alerts](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts) for callouts: `> [!NOTE]`, `> [!TIP]`, `> [!IMPORTANT]`, `> [!WARNING]`, `> [!CAUTION]`
|
||||
|
||||
```tf
|
||||
module "my_tool" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/namespace/my-tool/coder"
|
||||
version = "1.0.0"
|
||||
agent_id = coder_agent.main.id
|
||||
}
|
||||
```
|
||||
|
||||
## Icons
|
||||
|
||||
Modules reference icons in two places with different path systems:
|
||||
|
||||
- **README frontmatter** `icon:` uses a relative path to the repo's `.icons/` directory (e.g. `../../../../.icons/my-tool.svg`). Displayed on the registry website.
|
||||
- **`coder_script` / `coder_app`** `icon =` uses an absolute `/icon/<name>.svg` path served by the Coder deployment from `site/static/icon/` in the `coder/coder` repo. Displayed in the workspace agent bar.
|
||||
|
||||
Workflow:
|
||||
|
||||
1. **Check what exists.** List the `.icons/` directory at the repo root for available SVGs. For `/icon/` paths, look at what similar modules already use.
|
||||
2. **Use existing icons when they fit.** If the tool already has an icon in `.icons/` and `/icon/`, use those.
|
||||
3. **When an icon doesn't exist,** reference the expected path anyway (e.g. `../../../../.icons/my-tool.svg` and `/icon/my-tool.svg`) so the structure is correct. Try to source the official SVG from the tool's branding page or repository. If you can obtain it, add it to `.icons/` in this repo.
|
||||
4. **Don't substitute a generic icon.** If the tool has its own brand identity, use the correct name even if the file doesn't exist yet. Don't fall back to generic icons like `coder.svg` or `terminal.svg`.
|
||||
5. **Track missing icons** so you can report them in your response.
|
||||
|
||||
## Scripts
|
||||
|
||||
Modules use three patterns for shell logic, depending on complexity:
|
||||
|
||||
### Root `run.sh` + `templatefile()` (simple modules)
|
||||
|
||||
A single `run.sh` at the module root, loaded via `templatefile()` to inject Terraform variables. Used by `code-server`, `vscode-web`, `git-clone`, `dotfiles`, `filebrowser`.
|
||||
|
||||
```tf
|
||||
resource "coder_script" "my_tool" {
|
||||
agent_id = var.agent_id
|
||||
display_name = "My Tool"
|
||||
icon = "/icon/my-tool.svg"
|
||||
script = templatefile("${path.module}/run.sh", {
|
||||
LOG_PATH : var.log_path,
|
||||
})
|
||||
run_on_start = true
|
||||
}
|
||||
```
|
||||
|
||||
Use `$${VAR}` (double dollar) in the shell script for Terraform `templatefile` escaping.
|
||||
|
||||
If a script sources external files (`$HOME/.bashrc`, `/etc/bashrc`, `/etc/os-release`), the `source` statement must come before `set -u`; CI enforces this ordering.
|
||||
|
||||
### `scripts/` directory + `file()` (complex modules)
|
||||
|
||||
Separate `scripts/install.sh` and `scripts/start.sh` loaded via `file()` into `locals`, then passed to a child module or encoded inline. Used by `coder/claude-code`, `coder-labs/copilot`, `coder-labs/codex`, `coder-labs/cursor-cli`, `coder/amazon-q` for example.
|
||||
|
||||
```tf
|
||||
locals {
|
||||
install_script = file("${path.module}/scripts/install.sh")
|
||||
start_script = file("${path.module}/scripts/start.sh")
|
||||
}
|
||||
```
|
||||
|
||||
Use `file()` when scripts don't need Terraform variable interpolation. For config templates, use a `templates/` directory with `templatefile()` (e.g. `coder/amazon-q/templates/agent-config.json.tpl`).
|
||||
|
||||
### Inline heredoc (minimal modules)
|
||||
|
||||
For trivial logic, embed the script directly in the `coder_script` resource. Used by `cursor`, `zed`.
|
||||
|
||||
Modules that use a `scripts/` directory often also have a `testdata/` directory containing mock scripts for testing (e.g. `testdata/my-tool-mock.sh`).
|
||||
|
||||
## Testing
|
||||
|
||||
### .tftest.hcl (Required)
|
||||
|
||||
Every module must have Terraform native tests. The file can be named `main.tftest.hcl` or `<module-name>.tftest.hcl`. Use `command = plan` for most cases:
|
||||
|
||||
```hcl
|
||||
run "plan_with_defaults" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-id"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.agent_id == "test-agent-id"
|
||||
error_message = "agent_id should be set"
|
||||
}
|
||||
}
|
||||
|
||||
run "custom_port" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-id"
|
||||
port = 8080
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = resource.coder_app.my_tool.url == "http://localhost:8080"
|
||||
error_message = "App URL should use configured port"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Advanced patterns:
|
||||
|
||||
- `override_data` to mock data sources like `coder_workspace` and `coder_workspace_owner`
|
||||
- `command = apply` when testing outputs or computed values
|
||||
- `expect_failures` to test validation rules
|
||||
- `regexall()` / `startswith()` / `endswith()` for string assertions
|
||||
- Assert on `coder_env`, `coder_script`, `coder_app` resource attributes
|
||||
|
||||
```hcl
|
||||
run "with_mocked_workspace" {
|
||||
command = apply
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
}
|
||||
|
||||
override_data {
|
||||
target = data.coder_workspace.me
|
||||
values = {
|
||||
name = "test-workspace"
|
||||
}
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.url == "expected-value"
|
||||
error_message = "URL should match expected format"
|
||||
}
|
||||
}
|
||||
|
||||
run "validation_rejects_conflict" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test"
|
||||
option_a = true
|
||||
option_b = true
|
||||
}
|
||||
|
||||
expect_failures = [
|
||||
var.option_a,
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### main.test.ts (Optional)
|
||||
|
||||
For more complex testing (Docker containers, script execution, HTTP mocking).
|
||||
Import from `~test` (mapped to `test/test.ts` via `tsconfig.json`):
|
||||
|
||||
```typescript
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
runTerraformApply,
|
||||
runTerraformInit,
|
||||
testRequiredVariables,
|
||||
findResourceInstance,
|
||||
} from "~test";
|
||||
|
||||
describe("my-tool", () => {
|
||||
it("should init successfully", async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
});
|
||||
|
||||
testRequiredVariables(import.meta.dir, {
|
||||
agent_id: "test-agent",
|
||||
});
|
||||
|
||||
it("should apply with defaults", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "test-agent",
|
||||
});
|
||||
const app = findResourceInstance(state, "coder_app");
|
||||
expect(app.slug).toBe("my-tool");
|
||||
expect(app.display_name).toBe("My Tool");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Test utility API (`~test`)
|
||||
|
||||
**Terraform helpers:**
|
||||
|
||||
- `runTerraformInit(dir)`: runs `terraform init`.
|
||||
- `runTerraformApply(dir, vars, customEnv?)`: runs `terraform apply` with a random state file and returns `TerraformState`. Variables are passed as `TF_VAR_*`. Safe to run in parallel. `TerraformState` has `outputs: Record<string, TerraformOutput>` and `resources: TerraformStateResource[]`.
|
||||
- `testRequiredVariables(dir, vars)`: auto-generates test cases (one success with all vars, plus one per var verifying apply fails without it). Pass `{}` if there are no required vars.
|
||||
- `findResourceInstance(state, type, name?)`: finds the first resource instance by type. Throws if not found. Optionally filters by name.
|
||||
|
||||
**Docker helpers** (require `--network host`, Linux/Colima/OrbStack):
|
||||
|
||||
- `runContainer(image, init?)`: starts a detached container and returns its ID. Labeled `modules-test=true` for auto-cleanup.
|
||||
- `removeContainer(id)`: force-removes a container.
|
||||
- `execContainer(id, cmd[], args?[])`: runs a command in a container and returns `{ exitCode, stdout, stderr }`.
|
||||
- `executeScriptInContainer(state, image, shell?, before?)`: finds `coder_script` in state, runs it in a container, and returns `{ exitCode, stdout: string[], stderr: string[] }`.
|
||||
|
||||
**File helpers:**
|
||||
|
||||
- `writeCoder(id, script)`: writes a mock `coder` CLI to `/usr/bin/coder` in the container.
|
||||
- `writeFileContainer(id, path, content, { user? })`: writes a file to the container via base64.
|
||||
- `readFileContainer(id, path)`: reads a file from the container as root.
|
||||
|
||||
**HTTP helpers:**
|
||||
|
||||
- `createJSONResponse(obj, statusCode?)`: creates a `Response` with a JSON body (defaults to 200).
|
||||
|
||||
Cleanup of `*.tfstate` files and `modules-test` Docker containers is handled automatically by `setup.ts` (preloaded via `bunfig.toml`).
|
||||
|
||||
## Commands
|
||||
|
||||
| Task | Command | Scope |
|
||||
| ---------------- | ----------------------------------------------------- | ---------- |
|
||||
| Format all | `bun run fmt` | Repo |
|
||||
| Terraform tests | `bun run tftest` | Repo |
|
||||
| TypeScript tests | `bun run tstest` | Repo |
|
||||
| Single TF test | `terraform init -upgrade && terraform test -verbose` | Module dir |
|
||||
| Single TS test | `bun test main.test.ts` | Module dir |
|
||||
| Validate | `./scripts/terraform_validate.sh` | Repo |
|
||||
| ShellCheck | `bun run shellcheck` | Repo |
|
||||
| Version bump | `.github/scripts/version-bump.sh patch\|minor\|major` | Repo |
|
||||
|
||||
## Version Management
|
||||
|
||||
Bump version via `.github/scripts/version-bump.sh` when modifying modules:
|
||||
|
||||
- `patch`: bugfixes
|
||||
- `minor`: new features, new variables with defaults
|
||||
- `major`: breaking changes (removed inputs, changed defaults, new required variables)
|
||||
|
||||
The script automatically updates `version` references in README usage examples.
|
||||
|
||||
## Final Checks
|
||||
|
||||
Before considering the work complete, verify:
|
||||
|
||||
- Tests pass: `bun run tftest` and `bun run tstest`
|
||||
- `bun run fmt` has been run
|
||||
- `bun run shellcheck` passes if the module includes shell scripts
|
||||
- New variables have sensible defaults for backward compatibility
|
||||
- Breaking changes are documented if any inputs were removed, defaults changed, or new required variables added
|
||||
- Shell scripts handle errors gracefully (`|| echo "Warning..."` for non-fatal failures)
|
||||
- No hardcoded values that should be configurable via variables
|
||||
- Asset and icon paths in frontmatter and Terraform must be relative (e.g. `../../../../.icons/`), not absolute. External hyperlinks to docs or other websites are fine.
|
||||
|
||||
## Response to the User
|
||||
|
||||
In your response, include:
|
||||
|
||||
- If a new namespace was created, remind the user to fill out the namespace README (`display_name`, `bio`, `status`, `github`, etc.) and replace the placeholder avatar. Note that this is only needed if they plan to contribute to the registry.
|
||||
- If any icons were referenced but not found, list them and note they need to be sourced and added to both this repo's `.icons/` directory and the `coder/coder` repo at `site/static/icon/`.
|
||||
- A note that to contribute the module to the public registry, they can open a pull request to <https://github.com/coder/registry>.
|
||||
@@ -0,0 +1,321 @@
|
||||
---
|
||||
name: coder-templates
|
||||
description: Creates and updates Coder Registry workspace templates with agent setup, infrastructure provisioning, and module consumption
|
||||
---
|
||||
|
||||
# Coder Templates
|
||||
|
||||
Coder workspace templates are complete workspace definitions that live under `registry/<namespace>/templates/<name>/` and provision the infrastructure that workspaces run on.
|
||||
|
||||
## Before You Start
|
||||
|
||||
Before writing or modifying any code:
|
||||
|
||||
1. **Understand the request.** What platform is the template targeting (Docker, AWS, GCP, Azure, Kubernetes)? What kind of workspace (VM, container, devcontainer)?
|
||||
2. **Research existing templates and modules.** Look under `registry/` in this repo for similar templates and modules first; if you are not in the repo or cannot find a match, browse <https://registry.coder.com>. Read `main.tf` to understand patterns for that platform, especially how they handle agent setup, persistent storage, and module consumption. Prefer platform-specific helper modules (e.g. region selectors) that provide ready-made `coder_parameter` blocks over hard-coding option lists.
|
||||
3. **Check provider docs.** Verify the infrastructure provider resources you plan to use. Check both the Coder provider and the platform provider (AWS, Docker, etc.) version-specific docs if needed.
|
||||
4. **Clarify before building.** If the request is ambiguous (e.g. unclear platform, whether to use devcontainers vs plain VMs, what parameters to expose, or which namespace to use), ask for clarification rather than guessing. Never assume a namespace; always confirm with the user.
|
||||
5. **Plan the structure.** Decide on infrastructure resources, what `coder_parameter` options to expose, which registry modules to consume, and whether additional files like cloud-init configs are needed. When the user describes requirements in terms of their development needs rather than specific Terraform changes (e.g. "I need Node 20 + Postgres 16" or "make this template work for data science"), summarize what you plan to add or change before proceeding. Keep it brief: list the parameters, modules, and infrastructure changes. Skip this for straightforward requests where the action is clear (e.g. "add the code-server module" or "change the default region to us-west-2").
|
||||
|
||||
When updating an existing template, read and understand all of its current resources, parameters, and module consumption before making changes. If you observe patterns that deviate from the coder template standards (e.g. missing metadata blocks, hardcoded values that should be parameters, inline implementations that existing modules could replace, missing error handling in scripts), note these to the user as improvement opportunities in your response.
|
||||
|
||||
Always prefer the proper implementation over a simpler shortcut. Templates are infrastructure that users depend on. Doing less work is not the same as reducing complexity if it leaves the template incomplete or fragile.
|
||||
|
||||
Features marked as "Premium" in this skill require a Coder Premium license. When your implementation uses a Premium feature, note this in your response to the user so they can verify their deployment supports it.
|
||||
|
||||
## Documentation References
|
||||
|
||||
### Coder
|
||||
|
||||
- Platform docs (latest): <https://coder.com/docs>
|
||||
- Version-specific docs: `https://coder.com/docs/@v{MAJOR}.{MINOR}.{PATCH}` (e.g. <https://coder.com/docs/@v2.31.5>)
|
||||
- Creating templates: <https://coder.com/docs/admin/templates/creating-templates>
|
||||
- Extending templates: <https://coder.com/docs/admin/templates/extending-templates>
|
||||
- Template parameters: <https://coder.com/docs/admin/templates/extending-templates/parameters>
|
||||
- Dynamic parameters: <https://coder.com/docs/admin/templates/extending-templates/dynamic-parameters>
|
||||
- Workspace presets: <https://coder.com/docs/admin/templates/extending-templates/parameters#workspace-presets>
|
||||
- Prebuilt workspaces: <https://coder.com/docs/admin/templates/extending-templates/prebuilt-workspaces>
|
||||
- Tasks: <https://coder.com/docs/ai-coder/tasks>
|
||||
- Agent Boundaries: <https://coder.com/docs/ai-coder/agent-boundaries>
|
||||
- Coder Registry: <https://registry.coder.com>
|
||||
|
||||
### Coder Terraform provider
|
||||
|
||||
- Provider docs (latest): <https://registry.terraform.io/providers/coder/coder/latest/docs>
|
||||
- Version-specific provider docs: replace `latest` with a version number (e.g. <https://registry.terraform.io/providers/coder/coder/2.13.1/docs>)
|
||||
|
||||
Resources:
|
||||
|
||||
| Resource | Docs |
|
||||
| ---------------- | ------------------------------------------------------------------------------------ |
|
||||
| `coder_agent` | <https://registry.terraform.io/providers/coder/coder/latest/docs/resources/agent> |
|
||||
| `coder_app` | <https://registry.terraform.io/providers/coder/coder/latest/docs/resources/app> |
|
||||
| `coder_script` | <https://registry.terraform.io/providers/coder/coder/latest/docs/resources/script> |
|
||||
| `coder_env` | <https://registry.terraform.io/providers/coder/coder/latest/docs/resources/env> |
|
||||
| `coder_metadata` | <https://registry.terraform.io/providers/coder/coder/latest/docs/resources/metadata> |
|
||||
| `coder_ai_task` | <https://registry.terraform.io/providers/coder/coder/latest/docs/resources/ai_task> |
|
||||
|
||||
Data sources:
|
||||
|
||||
| Data Source | Docs |
|
||||
| ------------------------ | ----------------------------------------------------------------------------------------------- |
|
||||
| `coder_parameter` | <https://registry.terraform.io/providers/coder/coder/latest/docs/data-sources/parameter> |
|
||||
| `coder_workspace` | <https://registry.terraform.io/providers/coder/coder/latest/docs/data-sources/workspace> |
|
||||
| `coder_workspace_owner` | <https://registry.terraform.io/providers/coder/coder/latest/docs/data-sources/workspace_owner> |
|
||||
| `coder_provisioner` | <https://registry.terraform.io/providers/coder/coder/latest/docs/data-sources/provisioner> |
|
||||
| `coder_workspace_preset` | <https://registry.terraform.io/providers/coder/coder/latest/docs/data-sources/workspace_preset> |
|
||||
| `coder_task` | <https://registry.terraform.io/providers/coder/coder/latest/docs/data-sources/task> |
|
||||
|
||||
### Terraform providers commonly used in templates
|
||||
|
||||
All provider docs follow `https://registry.terraform.io/providers/ORG/NAME/latest/docs`:
|
||||
|
||||
| Provider | Source |
|
||||
| ---------- | ---------------------- |
|
||||
| Docker | `kreuzwerker/docker` |
|
||||
| AWS | `hashicorp/aws` |
|
||||
| Azure | `hashicorp/azurerm` |
|
||||
| GCP | `hashicorp/google` |
|
||||
| Kubernetes | `hashicorp/kubernetes` |
|
||||
| Cloud-Init | `hashicorp/cloudinit` |
|
||||
|
||||
Browse all providers: <https://registry.terraform.io/browse/providers>
|
||||
|
||||
## Scaffolding a New Template
|
||||
|
||||
Only use this when creating a brand new template that does not yet exist. When updating an existing template, edit its files directly.
|
||||
|
||||
From repo root:
|
||||
|
||||
```bash
|
||||
./scripts/new_template.sh namespace/template-name
|
||||
```
|
||||
|
||||
Names must be lowercase alphanumeric with hyphens (e.g. `my-org/aws-ec2`). Underscores are not allowed.
|
||||
|
||||
Creates `registry/<namespace>/templates/<template-name>/` with:
|
||||
|
||||
- `main.tf`: full workspace Terraform config with common patterns — read this as the primary reference for template structure
|
||||
- `README.md`: frontmatter and documentation
|
||||
|
||||
If the namespace is new, the script also creates `registry/<namespace>/` with a README. New namespaces additionally need:
|
||||
|
||||
- `registry/<namespace>/.images/avatar.svg` (or `.png`): square image, 400x400px minimum
|
||||
- The namespace README `avatar` field pointing to `./.images/avatar.svg`
|
||||
|
||||
The scaffolding script does not create the `.images/` directory or avatar file. When a new namespace is created, create `registry/<namespace>/.images/` and add a placeholder `avatar.svg` so the directory structure is ready for the user to replace with their real avatar.
|
||||
|
||||
The generated namespace README contains placeholder fields (`display_name`, `bio`, `status`, `github`, `avatar`, etc.) that the user must fill out. The `status` field is required and must be `official`, `partner`, or `community` (typically `community` for new contributors).
|
||||
|
||||
## Key Patterns
|
||||
|
||||
- Provider version constraints must reflect actual functionality requirements. Only set a minimum `coder` provider version when the template uses a resource, attribute, or behavior introduced in that version. The same applies to infrastructure providers (Docker, AWS, etc.); check provider changelogs to confirm.
|
||||
- Include `data.coder_workspace.me` and `data.coder_workspace_owner.me` for workspace and owner metadata. Include `data.coder_provisioner.me` only when you need the provisioner's `arch` or `os` for `coder_agent` (typical for Docker, Kubernetes, Incus); omit when the workspace OS/arch is fixed (e.g. cloud VMs with a known image).
|
||||
- Use `locals {}` for computed values: username, environment variables, startup scripts, URL assembly
|
||||
- Use `data.coder_workspace.me.start_count` as `count` on ephemeral resources
|
||||
- Connect containers/VMs to the agent via `coder_agent.main.init_script` and `CODER_AGENT_TOKEN`
|
||||
- Add `metadata` blocks for workspace dashboard stats (`coder stat cpu`, `coder stat mem`, etc.)
|
||||
- Use `coder_metadata` on the primary compute resource to surface key details (region, instance type, image, disk size) in the workspace dashboard
|
||||
- Optionally use `display_apps` block to hide specific built-in apps (defaults show all)
|
||||
- Before implementing functionality from scratch, look for an existing module under `registry/*/modules/` in this repo; if you cannot find one or are not in the repo, search <https://registry.coder.com>. If a module already exists for what you need, consume it rather than reimplementing it. When multiple modules serve similar purposes, prefer the actively maintained one and check that you are not using a deprecated or superseded module.
|
||||
- Before consuming a module, read its `main.tf` and `README.md` to understand the full interface: accepted variables, outputs, prerequisites, and runtime requirements. Prefer paths under `registry/<namespace>/modules/<name>/` in this workspace; otherwise use `https://registry.coder.com/modules/<namespace>/<module-name>`. Never pass arguments without confirming they exist.
|
||||
- After identifying a module's prerequisites, verify the template's base image satisfies them. If it lacks a required tool, either switch to an image that includes it or ensure the prerequisite is installed before the module's script runs. These runtime issues are not caught by `terraform validate`; they only surface when the workspace starts.
|
||||
- Module source URLs use `registry.coder.com/<namespace>/<module>/coder`. Older templates may use `registry.coder.com/modules/...`; prefer the shorter form when writing new modules or templates.
|
||||
- Label infrastructure resources with `coder.owner` and `coder.workspace_id` for tracking orphans
|
||||
- Use `lifecycle { ignore_changes = all }` on persistent volumes to prevent data loss
|
||||
- Do not add comments that narrate what the code does or label sections. Only comment when explaining something non-obvious (e.g. why a workaround exists, a subtle constraint, or an unusual design choice).
|
||||
|
||||
### Additional files
|
||||
|
||||
Templates can include files beyond `main.tf` + `README.md`:
|
||||
|
||||
- `cloud-init/*.tftpl`: cloud-init configs for VM provisioning (AWS, Azure, GCP), loaded via `templatefile()`. Prefer this subdirectory over placing cloud-init files at the template root.
|
||||
- `build/Dockerfile`: custom container images built by the template
|
||||
- `.tftpl` files: any Terraform template files for scripts, configs, or cloud-init data
|
||||
|
||||
### Parameters
|
||||
|
||||
Use `data "coder_parameter"` for user-facing workspace options. Typical parameters: region/instance type/CPU/memory/disk for cloud VMs; container image or runtime version for Docker (pass as `build_arg` when using a local Dockerfile). Use same-platform templates in `registry/` as a starting reference, not a rigid pattern. Expose stated preferences as the parameter `default` with additional sensible `option` values unless the user explicitly restricts it.
|
||||
|
||||
- Prefer `dynamic "option"` blocks with `for_each` from a `locals` map over static `option` blocks. See the region selector modules (e.g. `coder/aws-region`) for the pattern.
|
||||
- Use `form_type` for richer UI controls: `dropdown` (searchable), `multi-select` (for `list(string)`), `slider` (numeric), `radio`, `checkbox`, `textarea`.
|
||||
- Conditional parameters: use `count` to show/hide a parameter based on another parameter's value.
|
||||
- `mutable = false` for infrastructure that can't change after creation (region, disk); `mutable = true` for runtime config.
|
||||
- `ephemeral = true` for one-shot build options that don't persist between starts.
|
||||
- `validation {}` with `min`/`max`/`monotonic` for numbers, `regex`/`error` for strings.
|
||||
- Dynamic parameter features require Coder provider `>= 2.4.0`.
|
||||
|
||||
### Presets
|
||||
|
||||
Workspace presets bundle commonly-used parameter combinations into selectable options. When a user creates a workspace, they can pick a preset to auto-fill multiple parameters at once. Define presets with `data "coder_workspace_preset"`:
|
||||
|
||||
```tf
|
||||
data "coder_workspace_preset" "default" {
|
||||
name = "Standard Dev Environment"
|
||||
default = true
|
||||
|
||||
parameters = {
|
||||
"region" = "us-east-1"
|
||||
"cpu" = "4"
|
||||
"memory" = "8"
|
||||
"container_image" = "codercom/enterprise-base:ubuntu"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- The keys in `parameters` must match the `name` attribute of `coder_parameter` data sources in the same template.
|
||||
- Set `default = true` on at most one preset to pre-select it in the UI.
|
||||
- A template can define multiple presets for different use cases.
|
||||
- Optional fields: `description` (context text in UI) and `icon` (e.g. `/emojis/1f680.png`).
|
||||
|
||||
### Prebuilds (Premium)
|
||||
|
||||
Prebuilds maintain an automatically-managed pool of pre-provisioned workspaces for a preset, reducing workspace creation time. This is a Premium feature. Prebuilds are configured as a nested block inside a preset:
|
||||
|
||||
```tf
|
||||
data "coder_workspace_preset" "goland" {
|
||||
name = "GoLand: Large"
|
||||
parameters = {
|
||||
"jetbrains_ide" = "GO"
|
||||
"cpu" = "8"
|
||||
"memory" = "16"
|
||||
}
|
||||
|
||||
prebuilds {
|
||||
instances = 3
|
||||
|
||||
expiration_policy {
|
||||
ttl = 86400
|
||||
}
|
||||
|
||||
scheduling {
|
||||
timezone = "UTC"
|
||||
schedule {
|
||||
cron = "* 8-18 * * 1-5"
|
||||
instances = 5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `instances`: number of prebuilt workspaces to keep in the pool (base count when no schedule matches).
|
||||
- `expiration_policy.ttl`: seconds before unclaimed prebuilds are cleaned up.
|
||||
- `scheduling`: scale the pool up or down on a time-based cron schedule. The `cron` minute field must always be `*`.
|
||||
- The preset must define all required parameters needed to build the workspace.
|
||||
- When a prebuild is claimed, ownership transfers to the real user. Use `lifecycle { ignore_changes = [...] }` on resources that reference owner-specific values to prevent unnecessary recreation.
|
||||
|
||||
### Task-Oriented Templates
|
||||
|
||||
A template becomes task-capable by adding a `coder_ai_task` resource, which enables the Coder Tasks UI for AI agent workflows. Task templates require three additions on top of a regular template:
|
||||
|
||||
```tf
|
||||
resource "coder_ai_task" "task" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
app_id = module.claude-code[count.index].task_app_id
|
||||
}
|
||||
|
||||
data "coder_task" "me" {}
|
||||
|
||||
module "claude-code" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "~> 4.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/projects"
|
||||
ai_prompt = data.coder_task.me.prompt
|
||||
system_prompt = data.coder_parameter.system_prompt.value
|
||||
model = "sonnet"
|
||||
permission_mode = "plan"
|
||||
enable_boundary = true
|
||||
}
|
||||
```
|
||||
|
||||
- `coder_ai_task`: declares the template as task-capable. Its `app_id` must point to the agent module's `task_app_id` output.
|
||||
- `data "coder_task"`: reads the user's task prompt. Pass it to the agent module via `ai_prompt`.
|
||||
- Agent module: consume an AI agent module (`claude-code`, `codex`, etc.) with task-specific variables. Key variables include `ai_prompt`, `system_prompt`, `permission_mode`, and `enable_boundary`.
|
||||
- Boundaries: set `enable_boundary = true` on the agent module to enable network-level filtering for the AI agent. See <https://coder.com/docs/ai-coder/agent-boundaries> for allowlist configuration.
|
||||
- A `coder_app` with `slug = "preview"` gets special treatment in the Tasks UI navbar.
|
||||
- Task templates heavily use presets to define scenarios (different repos, system prompts, setup scripts, container images).
|
||||
- See `registry/coder-labs/templates/tasks-docker` as a reference implementation.
|
||||
|
||||
Docs: <https://coder.com/docs/ai-coder/tasks>
|
||||
|
||||
## README.md
|
||||
|
||||
Required YAML frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
display_name: Docker Containers
|
||||
description: Provision Docker containers with persistent home volumes as Coder workspaces
|
||||
icon: ../../../../.icons/docker.svg
|
||||
verified: false
|
||||
tags: [docker, container]
|
||||
---
|
||||
```
|
||||
|
||||
Content rules:
|
||||
|
||||
- Single H1 heading matching `display_name`, directly below frontmatter
|
||||
- When increasing header levels, increment by one each time (h1 -> h2 -> h3, not h1 -> h3)
|
||||
- Opening paragraph describing what the template provisions. Be specific about the platform, compute type, and key capabilities (e.g. "Provision Kubernetes pods on an existing Amazon EKS cluster as Coder workspaces with persistent home volumes") rather than generic (e.g. "AWS Kubernetes template"). The frontmatter `description` field should follow the same principle.
|
||||
- **Prerequisites** section (infrastructure requirements, provider credentials)
|
||||
- **Architecture** section (what resources are created, what's ephemeral vs persistent)
|
||||
- Code fences labeled `tf` (NOT `hcl`)
|
||||
- Relative icon paths (e.g. `../../../../.icons/`)
|
||||
- **Do NOT include tables or lists that enumerate variables, parameters, or outputs.** The registry generates variable and output documentation automatically from the Terraform source. Workspace parameter options are visible in the Coder UI. Describe what the template does and how to use it in prose, not by listing every configurable field.
|
||||
- Use [GFM alerts](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts) for callouts: `> [!NOTE]`, `> [!TIP]`, `> [!IMPORTANT]`, `> [!WARNING]`, `> [!CAUTION]`
|
||||
|
||||
## Icons
|
||||
|
||||
Templates reference icons in the README frontmatter `icon:` field using a relative path to the repo's `.icons/` directory (e.g. `../../../../.icons/aws.svg`). This icon is displayed on the registry website.
|
||||
|
||||
Workflow:
|
||||
|
||||
1. **Check what exists.** List the `.icons/` directory at the repo root for available SVGs.
|
||||
2. **Use existing icons when they fit.** Most templates use a platform icon (aws, gcp, azure, docker, kubernetes) that already exists.
|
||||
3. **When an icon doesn't exist,** reference the expected path anyway so the structure is correct. Try to source the official SVG from the platform's branding page or repository. If you can obtain it, add it to `.icons/` in this repo.
|
||||
4. **Don't substitute a generic icon.** If the platform has its own brand identity, use the correct name even if the file doesn't exist yet.
|
||||
5. **Track missing icons** so you can report them in your response.
|
||||
|
||||
## Testing
|
||||
|
||||
Templates do NOT require `.tftest.hcl` or `main.test.ts`. Testing is done by pushing the template to a Coder deployment.
|
||||
|
||||
## Commands
|
||||
|
||||
| Task | Command | Scope |
|
||||
| ---------- | --------------------------------- | ----- |
|
||||
| Format all | `bun run fmt` | Repo |
|
||||
| Validate | `./scripts/terraform_validate.sh` | Repo |
|
||||
| ShellCheck | `bun run shellcheck` | Repo |
|
||||
|
||||
## Final Checks
|
||||
|
||||
Before considering the work complete, verify:
|
||||
|
||||
- `terraform init && terraform validate` passes in the template directory
|
||||
- `bun run fmt` has been run
|
||||
- `bun run shellcheck` passes if the template includes shell scripts
|
||||
- README documents prerequisites and architecture
|
||||
- Shell scripts handle errors gracefully (`|| echo "Warning..."` for non-fatal failures). If a script sources external files (`$HOME/.bashrc`, `/etc/bashrc`, `/etc/os-release`), the `source` must come before `set -u`; CI enforces this ordering.
|
||||
- No hardcoded values that should be configurable via variables or parameters
|
||||
- Asset and icon paths in frontmatter and Terraform must be relative (e.g. `../../../../.icons/`), not absolute. External hyperlinks to docs or other websites are fine.
|
||||
|
||||
## Response to the User
|
||||
|
||||
In your response, include:
|
||||
|
||||
- A ready-to-run push command with real values filled in. Use `-d` to point at the template directory (so it works from the repo root), `-m` for a short description, and `-y` to skip interactive prompts:
|
||||
|
||||
```bash
|
||||
coder templates push \
|
||||
registry/ \
|
||||
-m "Initial version: <brief description>" \
|
||||
-y < template-name > -d < namespace > /templates/ < template-name > /
|
||||
```
|
||||
|
||||
- If a new namespace was created, remind the user to fill out the namespace README (`display_name`, `bio`, `status`, `github`, etc.) and replace the placeholder avatar. Note that this is only needed if they plan to contribute to the registry.
|
||||
- If any icons were referenced but not found, list them and note they need to be sourced and added to both this repo's `.icons/` directory and the `coder/coder` repo at `site/static/icon/`.
|
||||
- A note that to contribute the template to the public registry, they can open a pull request to <https://github.com/coder/registry>.
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../.agents/skills
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
all:
|
||||
- '**'
|
||||
- name: Set up Terraform
|
||||
uses: coder/coder/.github/actions/setup-tf@1a774ab7ce99063a2e01beb94de3fcbccaf84dbe # v2.31.5
|
||||
uses: coder/coder/.github/actions/setup-tf@2f5d21d1be7864b3e21d9c0b8e87d3ba229a1140 # v2.31.9
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
@@ -87,13 +87,13 @@ jobs:
|
||||
bun-version: latest
|
||||
# Need Terraform for its formatter
|
||||
- name: Install Terraform
|
||||
uses: coder/coder/.github/actions/setup-tf@1a774ab7ce99063a2e01beb94de3fcbccaf84dbe # v2.31.5
|
||||
uses: coder/coder/.github/actions/setup-tf@2f5d21d1be7864b3e21d9c0b8e87d3ba229a1140 # v2.31.9
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
- name: Validate formatting
|
||||
run: bun fmt:ci
|
||||
- name: Check for typos
|
||||
uses: crate-ci/typos@631208b7aac2daa8b707f55e7331f9112b0e062d # v1.44.0
|
||||
uses: crate-ci/typos@02ea592e44b3a53c302f697cddca7641cd051c3d # v1.45.0
|
||||
with:
|
||||
config: .github/typos.toml
|
||||
validate-readme-files:
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version: "1.24.0"
|
||||
- name: Validate contributors
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version: stable
|
||||
- name: golangci-lint
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
bun-version: latest
|
||||
|
||||
- name: Set up Terraform
|
||||
uses: coder/coder/.github/actions/setup-tf@1a774ab7ce99063a2e01beb94de3fcbccaf84dbe # v2.31.5
|
||||
uses: coder/coder/.github/actions/setup-tf@2f5d21d1be7864b3e21d9c0b8e87d3ba229a1140 # v2.31.9
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
|
||||
- name: Comment on PR - Version bump required
|
||||
if: failure()
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>1Password</title><circle cx="12" cy="12" r="12" fill="#0572EC"/><path fill="#fff" d="M11.105 4.864h1.788c.484 0 .729.002.914.096a.86.86 0 0 1 .377.377c.094.185.095.428.095.912v6.016c0 .12 0 .182-.015.238a.427.427 0 0 1-.067.137.923.923 0 0 1-.174.162l-.695.564c-.113.092-.17.138-.191.194a.216.216 0 0 0 0 .15c.02.055.078.101.191.193l.695.565c.094.076.14.115.174.162.03.042.053.087.067.137a.936.936 0 0 1 .015.238v2.746c0 .484-.001.727-.095.912a.86.86 0 0 1-.377.377c-.185.094-.43.096-.914.096h-1.788c-.484 0-.726-.002-.912-.096a.86.86 0 0 1-.377-.377c-.094-.185-.095-.428-.095-.912v-6.016c0-.12 0-.182.015-.238a.437.437 0 0 1 .067-.139c.034-.047.08-.083.174-.16l.695-.564c.113-.092.17-.138.191-.194a.216.216 0 0 0 0-.15c-.02-.055-.078-.101-.191-.193l-.695-.565a.92.92 0 0 1-.174-.162.437.437 0 0 1-.067-.139.92.92 0 0 1-.015-.236V6.25c0-.484.001-.727.095-.912a.86.86 0 0 1 .377-.377c.186-.094.428-.096.912-.096z"/></svg>
|
||||
|
After Width: | Height: | Size: 999 B |
@@ -1 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="800" viewBox="0 0 32 32"><title>file_type_devcontainer</title><circle cx="16" cy="16" r="14" style="fill:#193e63"/><polygon points="10.777 22.742 9.343 21.348 12.729 17.865 9.346 14.417 10.774 13.017 15.525 17.859 10.777 22.742" style="fill:#add1ea"/><polygon points="21.42 19.101 22.854 17.706 19.468 14.224 22.851 10.776 21.423 9.376 16.672 14.218 21.42 19.101" style="fill:#add1ea"/></svg>
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.8461 2.7571C15.2325 2.22387 16.7675 2.22387 18.1539 2.7571L28.0769 6.57367C29.2355 7.01927 30 8.13239 30 9.3737V22.6265C30 23.8678 29.2355 24.9809 28.0769 25.4265L18.1539 29.2431C16.7675 29.7763 15.2325 29.7763 13.8461 29.2431L3.92306 25.4265C2.76449 24.9809 2 23.8678 2 22.6265V9.3737C2 8.13239 2.76449 7.01927 3.92306 6.57367L13.8461 2.7571ZM9.39418 10.0809C8.88655 9.86331 8.29867 10.0985 8.08111 10.6061C7.86356 11.1137 8.09871 11.7016 8.60634 11.9192L15.0003 14.6594V21C15.0003 21.5523 15.448 22 16.0003 22C16.5525 22 17.0003 21.5523 17.0003 21V14.6594L23.3942 11.9192C23.9018 11.7016 24.137 11.1137 23.9194 10.6061C23.7018 10.0985 23.114 9.86331 22.6063 10.0809L16.0003 12.912L9.39418 10.0809Z" fill="#212121"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 452 B After Width: | Height: | Size: 834 B |
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
@@ -0,0 +1,11 @@
|
||||
---
|
||||
display_name: Ben Potter
|
||||
bio: Tinkerer and Product Manager at Coder
|
||||
github: bpmct
|
||||
avatar: ./.images/avatar.png
|
||||
status: community
|
||||
---
|
||||
|
||||
# Ben Potter
|
||||
|
||||
Tinkerer and Product Manager at Coder. Building modules to make dev environments better.
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
display_name: "1Password"
|
||||
description: "Install the 1Password CLI and VS Code extension in your Coder workspace"
|
||||
icon: ../../../../.icons/1password.svg
|
||||
verified: false
|
||||
tags: [integration, 1password, secrets]
|
||||
---
|
||||
|
||||
# 1Password
|
||||
|
||||
Install the [1Password CLI](https://developer.1password.com/docs/cli/)
|
||||
(`op`) in your Coder workspace and optionally authenticate with a service
|
||||
account token. Can also install the
|
||||
[1Password VS Code extension](https://marketplace.visualstudio.com/items?itemName=1Password.op-vscode)
|
||||
for code-server and VS Code.
|
||||
|
||||

|
||||
|
||||
```tf
|
||||
module "onepassword" {
|
||||
source = "registry.coder.com/bpmct/onepassword/coder"
|
||||
version = "1.0.2"
|
||||
agent_id = coder_agent.main.id
|
||||
service_account_token = var.op_service_account_token
|
||||
}
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
### Service Account (recommended)
|
||||
|
||||
Create a [1Password service account](https://developer.1password.com/docs/service-accounts/get-started/)
|
||||
and pass the token as a Terraform variable. The module sets
|
||||
`OP_SERVICE_ACCOUNT_TOKEN` in the workspace so `op` commands work
|
||||
immediately.
|
||||
|
||||
```tf
|
||||
variable "op_service_account_token" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
module "onepassword" {
|
||||
source = "registry.coder.com/bpmct/onepassword/coder"
|
||||
version = "1.0.2"
|
||||
agent_id = coder_agent.main.id
|
||||
service_account_token = var.op_service_account_token
|
||||
}
|
||||
```
|
||||
|
||||
### Personal Account
|
||||
|
||||
Pass your account details and the module will pre-register the account.
|
||||
You'll be prompted for your password when you run `op signin` in the
|
||||
terminal.
|
||||
|
||||
```tf
|
||||
module "onepassword" {
|
||||
source = "registry.coder.com/bpmct/onepassword/coder"
|
||||
version = "1.0.2"
|
||||
agent_id = coder_agent.main.id
|
||||
account_address = "myteam.1password.com"
|
||||
account_email = "you@example.com"
|
||||
account_secret_key = var.op_secret_key
|
||||
}
|
||||
```
|
||||
|
||||
## VS Code Extension
|
||||
|
||||
Set `install_vscode_extension = true` to install the 1Password extension
|
||||
for code-server and VS Code.
|
||||
|
||||
```tf
|
||||
module "onepassword" {
|
||||
source = "registry.coder.com/bpmct/onepassword/coder"
|
||||
version = "1.0.2"
|
||||
agent_id = coder_agent.main.id
|
||||
service_account_token = var.op_service_account_token
|
||||
install_vscode_extension = true
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Scripts
|
||||
|
||||
Run custom logic before or after the CLI is installed.
|
||||
|
||||
```tf
|
||||
module "onepassword" {
|
||||
source = "registry.coder.com/bpmct/onepassword/coder"
|
||||
version = "1.0.2"
|
||||
agent_id = coder_agent.main.id
|
||||
service_account_token = var.op_service_account_token
|
||||
post_install_script = <<-EOT
|
||||
op read "op://Vault/item/field" > ~/.secret
|
||||
EOT
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,112 @@
|
||||
terraform {
|
||||
required_version = ">= 1.0"
|
||||
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
version = ">= 0.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "agent_id" {
|
||||
type = string
|
||||
description = "The ID of a Coder agent."
|
||||
}
|
||||
|
||||
variable "service_account_token" {
|
||||
type = string
|
||||
description = "A 1Password service account token. If set, account-based sign-in is skipped."
|
||||
default = ""
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "account_address" {
|
||||
type = string
|
||||
description = "The 1Password account sign-in address (e.g. myteam.1password.com)."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "account_email" {
|
||||
type = string
|
||||
description = "The email address for the 1Password account."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "account_secret_key" {
|
||||
type = string
|
||||
description = "The Secret Key for the 1Password account."
|
||||
default = ""
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "install_dir" {
|
||||
type = string
|
||||
description = "The directory to install the 1Password CLI to."
|
||||
default = "/usr/local/bin"
|
||||
}
|
||||
|
||||
variable "op_cli_version" {
|
||||
type = string
|
||||
description = "The version of the 1Password CLI to install."
|
||||
default = "latest"
|
||||
validation {
|
||||
condition = var.op_cli_version == "latest" || can(regex("^[0-9]+\\.[0-9]+\\.[0-9]+$", var.op_cli_version))
|
||||
error_message = "op_cli_version must be either 'latest' or a semantic version (e.g., '2.30.0')."
|
||||
}
|
||||
}
|
||||
|
||||
variable "install_vscode_extension" {
|
||||
type = bool
|
||||
description = "Install the 1Password VS Code extension for both VS Code and code-server."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "pre_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run before installing the 1Password CLI."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "post_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run after installing the 1Password CLI."
|
||||
default = null
|
||||
}
|
||||
|
||||
data "coder_parameter" "account_password" {
|
||||
count = var.account_address != "" && var.service_account_token == "" ? 1 : 0
|
||||
type = "string"
|
||||
name = "op_account_password"
|
||||
display_name = "1Password Account Password"
|
||||
description = "Your 1Password account password. Used to sign in to the CLI."
|
||||
mutable = true
|
||||
default = ""
|
||||
}
|
||||
|
||||
resource "coder_script" "onepassword" {
|
||||
agent_id = var.agent_id
|
||||
display_name = "1Password CLI"
|
||||
icon = "/icon/1password.svg"
|
||||
script = templatefile("${path.module}/run.sh", {
|
||||
SERVICE_ACCOUNT_TOKEN = var.service_account_token
|
||||
ACCOUNT_ADDRESS = var.account_address
|
||||
ACCOUNT_EMAIL = var.account_email
|
||||
ACCOUNT_SECRET_KEY = var.account_secret_key
|
||||
ACCOUNT_PASSWORD = var.account_address != "" && var.service_account_token == "" ? data.coder_parameter.account_password[0].value : ""
|
||||
INSTALL_DIR = var.install_dir
|
||||
OP_CLI_VERSION = var.op_cli_version
|
||||
INSTALL_VSCODE_EXTENSION = var.install_vscode_extension
|
||||
PRE_INSTALL_SCRIPT = var.pre_install_script != null ? base64encode(var.pre_install_script) : ""
|
||||
POST_INSTALL_SCRIPT = var.post_install_script != null ? base64encode(var.post_install_script) : ""
|
||||
})
|
||||
run_on_start = true
|
||||
start_blocks_login = true
|
||||
}
|
||||
|
||||
resource "coder_env" "op_service_account_token" {
|
||||
count = var.service_account_token != "" ? 1 : 0
|
||||
agent_id = var.agent_id
|
||||
name = "OP_SERVICE_ACCOUNT_TOKEN"
|
||||
value = var.service_account_token
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
SERVICE_ACCOUNT_TOKEN="${SERVICE_ACCOUNT_TOKEN}"
|
||||
ACCOUNT_ADDRESS="${ACCOUNT_ADDRESS}"
|
||||
ACCOUNT_EMAIL="${ACCOUNT_EMAIL}"
|
||||
ACCOUNT_SECRET_KEY="${ACCOUNT_SECRET_KEY}"
|
||||
ACCOUNT_PASSWORD="${ACCOUNT_PASSWORD}"
|
||||
INSTALL_DIR="${INSTALL_DIR}"
|
||||
OP_CLI_VERSION="${OP_CLI_VERSION}"
|
||||
INSTALL_VSCODE_EXTENSION="${INSTALL_VSCODE_EXTENSION}"
|
||||
PRE_INSTALL_SCRIPT="${PRE_INSTALL_SCRIPT}"
|
||||
POST_INSTALL_SCRIPT="${POST_INSTALL_SCRIPT}"
|
||||
|
||||
fetch() {
|
||||
if command -v curl > /dev/null 2>&1; then
|
||||
curl -sSL --fail "$1"
|
||||
elif command -v wget > /dev/null 2>&1; then
|
||||
wget -qO- "$1"
|
||||
else
|
||||
printf "curl or wget is not installed.\n" && return 1
|
||||
fi
|
||||
}
|
||||
|
||||
fetch_to_file() {
|
||||
if command -v curl > /dev/null 2>&1; then
|
||||
curl -sSL --fail "$2" -o "$1"
|
||||
elif command -v wget > /dev/null 2>&1; then
|
||||
wget -O "$1" "$2"
|
||||
else
|
||||
printf "curl or wget is not installed.\n" && return 1
|
||||
fi
|
||||
}
|
||||
|
||||
run_script() {
|
||||
local ENCODED="$1" LABEL="$2"
|
||||
if [ -n "$${ENCODED}" ]; then
|
||||
printf "Running %s script...\n" "$${LABEL}"
|
||||
SCRIPT_PATH=$(mktemp /tmp/op-"$${LABEL}"-XXXXXX.sh)
|
||||
printf '%s' "$${ENCODED}" | base64 -d > "$${SCRIPT_PATH}"
|
||||
chmod +x "$${SCRIPT_PATH}"
|
||||
# shellcheck disable=SC2288
|
||||
"$${SCRIPT_PATH}" || printf "WARNING: %s script failed.\n" "$${LABEL}"
|
||||
rm -f "$${SCRIPT_PATH}"
|
||||
fi
|
||||
}
|
||||
|
||||
install() {
|
||||
ARCH=$(uname -m)
|
||||
if [ "$${ARCH}" = "x86_64" ]; then
|
||||
ARCH="amd64"
|
||||
elif [ "$${ARCH}" = "aarch64" ]; then
|
||||
ARCH="arm64"
|
||||
else
|
||||
printf "Unsupported architecture: %s\n" "$${ARCH}" && return 1
|
||||
fi
|
||||
|
||||
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
if [ "$${OS}" != "linux" ] && [ "$${OS}" != "darwin" ]; then
|
||||
printf "Unsupported OS: %s\n" "$${OS}" && return 1
|
||||
fi
|
||||
|
||||
if [ "$${OP_CLI_VERSION}" = "latest" ]; then
|
||||
OP_CLI_VERSION=$(fetch "https://app-updates.agilebits.com/check/1/0/CLI2/en/2.0.0/N" \
|
||||
| grep -oE '"version":"[^"]+"' | head -1 | cut -d'"' -f4) || true
|
||||
if [ -z "$${OP_CLI_VERSION}" ]; then
|
||||
printf "Failed to resolve latest version, falling back to 2.30.3.\n"
|
||||
OP_CLI_VERSION="2.30.3"
|
||||
fi
|
||||
fi
|
||||
|
||||
printf "1Password CLI version: %s\n" "$${OP_CLI_VERSION}"
|
||||
|
||||
if command -v op > /dev/null 2>&1; then
|
||||
CURRENT_VERSION=$(op --version 2> /dev/null || true)
|
||||
if [ "$${CURRENT_VERSION}" = "$${OP_CLI_VERSION}" ]; then
|
||||
printf "Already installed.\n"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
DOWNLOAD_URL="https://cache.agilebits.com/dist/1P/op2/pkg/v$${OP_CLI_VERSION}/op_$${OS}_$${ARCH}_v$${OP_CLI_VERSION}.zip"
|
||||
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
cd "$${TEMP_DIR}" || return 1
|
||||
|
||||
if ! fetch_to_file op.zip "$${DOWNLOAD_URL}"; then
|
||||
rm -rf "$${TEMP_DIR}" && return 1
|
||||
fi
|
||||
|
||||
if command -v unzip > /dev/null 2>&1; then
|
||||
unzip -o op.zip -d . > /dev/null
|
||||
elif command -v busybox > /dev/null 2>&1; then
|
||||
busybox unzip op.zip -d .
|
||||
else
|
||||
printf "unzip is not installed.\n"
|
||||
rm -rf "$${TEMP_DIR}" && return 1
|
||||
fi
|
||||
|
||||
chmod +x op
|
||||
|
||||
if [ -n "$${INSTALL_DIR}" ] && [ -w "$${INSTALL_DIR}" ]; then
|
||||
mv op "$${INSTALL_DIR}/op"
|
||||
elif [ -n "$${INSTALL_DIR}" ] && sudo mv op "$${INSTALL_DIR}/op" 2> /dev/null; then
|
||||
true
|
||||
else
|
||||
mkdir -p ~/.local/bin && mv op ~/.local/bin/op
|
||||
INSTALL_DIR=~/.local/bin
|
||||
fi
|
||||
printf "Installed to %s.\n" "$${INSTALL_DIR}"
|
||||
|
||||
rm -rf "$${TEMP_DIR}"
|
||||
}
|
||||
|
||||
run_script "$${PRE_INSTALL_SCRIPT}" "pre-install"
|
||||
|
||||
if ! install; then
|
||||
printf "Failed to install 1Password CLI.\n"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$${SERVICE_ACCOUNT_TOKEN}" ]; then
|
||||
printf "Service account token configured.\n"
|
||||
elif [ -n "$${ACCOUNT_ADDRESS}" ] && [ -n "$${ACCOUNT_EMAIL}" ]; then
|
||||
ADD_ARGS="--address $${ACCOUNT_ADDRESS} --email $${ACCOUNT_EMAIL}"
|
||||
if [ -n "$${ACCOUNT_SECRET_KEY}" ]; then
|
||||
ADD_ARGS="$${ADD_ARGS} --secret-key $${ACCOUNT_SECRET_KEY}"
|
||||
fi
|
||||
|
||||
if [ -n "$${ACCOUNT_PASSWORD}" ] && command -v expect > /dev/null 2>&1; then
|
||||
OP_SESSION=$(expect -c "
|
||||
log_user 0
|
||||
spawn op account add $${ADD_ARGS} --raw
|
||||
expect \"Enter the password*\"
|
||||
send \"$${ACCOUNT_PASSWORD}\r\"
|
||||
expect eof
|
||||
catch wait result
|
||||
set output \$expect_out(buffer)
|
||||
puts -nonewline \$output
|
||||
" 2>&1)
|
||||
if op account list 2> /dev/null | grep -q "$${ACCOUNT_ADDRESS}"; then
|
||||
printf "Signed in to %s.\n" "$${ACCOUNT_ADDRESS}"
|
||||
if [ -n "$${OP_SESSION}" ]; then
|
||||
mkdir -p "$${HOME}/.op"
|
||||
SESSION_VAR="OP_SESSION_$(printf '%s' "$${ACCOUNT_ADDRESS}" | tr '.' '_' | tr '-' '_')"
|
||||
printf 'export %s="%s"\n' "$${SESSION_VAR}" "$${OP_SESSION}" > "$${HOME}/.op/session"
|
||||
chmod 600 "$${HOME}/.op/session"
|
||||
for rc in "$${HOME}/.bashrc" "$${HOME}/.zshrc"; do
|
||||
if [ -f "$${rc}" ] && ! grep -q ".op/session" "$${rc}" 2> /dev/null; then
|
||||
printf '\n[ -f ~/.op/session ] && . ~/.op/session\n' >> "$${rc}"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
else
|
||||
printf "Sign-in failed. Run manually: op signin --account %s\n" "$${ACCOUNT_ADDRESS}"
|
||||
fi
|
||||
else
|
||||
printf "To sign in, run in your terminal:\n"
|
||||
printf " op account add %s\n" "$${ADD_ARGS}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$${INSTALL_VSCODE_EXTENSION}" = "true" ]; then
|
||||
EXTENSION_ID="1Password.op-vscode"
|
||||
for _ in 1 2 3 4 5 6; do
|
||||
command -v code-server > /dev/null 2>&1 || command -v code > /dev/null 2>&1 && break
|
||||
sleep 5
|
||||
done
|
||||
if command -v code-server > /dev/null 2>&1; then
|
||||
cd /tmp && code-server --install-extension "$${EXTENSION_ID}" --force 2>&1 || true
|
||||
fi
|
||||
if command -v code > /dev/null 2>&1; then
|
||||
cd /tmp && code --install-extension "$${EXTENSION_ID}" --force 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
run_script "$${POST_INSTALL_SCRIPT}" "post-install"
|
||||
@@ -13,7 +13,7 @@ Run [GitHub Copilot CLI](https://docs.github.com/copilot/concepts/agents/about-c
|
||||
```tf
|
||||
module "copilot" {
|
||||
source = "registry.coder.com/coder-labs/copilot/coder"
|
||||
version = "0.4.0"
|
||||
version = "0.4.1"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder/projects"
|
||||
}
|
||||
@@ -51,7 +51,7 @@ data "coder_parameter" "ai_prompt" {
|
||||
|
||||
module "copilot" {
|
||||
source = "registry.coder.com/coder-labs/copilot/coder"
|
||||
version = "0.4.0"
|
||||
version = "0.4.1"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder/projects"
|
||||
|
||||
@@ -71,7 +71,7 @@ Customize tool permissions, MCP servers, and Copilot settings:
|
||||
```tf
|
||||
module "copilot" {
|
||||
source = "registry.coder.com/coder-labs/copilot/coder"
|
||||
version = "0.4.0"
|
||||
version = "0.4.1"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder/projects"
|
||||
|
||||
@@ -142,7 +142,7 @@ variable "github_token" {
|
||||
|
||||
module "copilot" {
|
||||
source = "registry.coder.com/coder-labs/copilot/coder"
|
||||
version = "0.4.0"
|
||||
version = "0.4.1"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder/projects"
|
||||
github_token = var.github_token
|
||||
@@ -156,7 +156,7 @@ Run Copilot as a command-line tool without task reporting or web interface. This
|
||||
```tf
|
||||
module "copilot" {
|
||||
source = "registry.coder.com/coder-labs/copilot/coder"
|
||||
version = "0.4.0"
|
||||
version = "0.4.1"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder"
|
||||
report_tasks = false
|
||||
@@ -179,7 +179,7 @@ module "aibridge-proxy" {
|
||||
|
||||
module "copilot" {
|
||||
source = "registry.coder.com/coder-labs/copilot/coder"
|
||||
version = "0.4.0"
|
||||
version = "0.4.1"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/projects"
|
||||
enable_aibridge_proxy = true
|
||||
|
||||
@@ -117,18 +117,23 @@ run "copilot_model_not_created_for_default" {
|
||||
}
|
||||
}
|
||||
|
||||
run "model_validation_accepts_valid_models" {
|
||||
run "copilot_model_accepts_custom_model" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workdir = "/home/coder"
|
||||
copilot_model = "gpt-5"
|
||||
copilot_model = "o3-pro"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = contains(["claude-sonnet-4", "claude-sonnet-4.5", "gpt-5"], var.copilot_model)
|
||||
error_message = "Model should be one of the valid options"
|
||||
condition = var.copilot_model == "o3-pro"
|
||||
error_message = "copilot_model should accept any model string"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(resource.coder_env.copilot_model) == 1
|
||||
error_message = "copilot_model env var should be created for non-default model"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,12 +33,8 @@ variable "github_token" {
|
||||
|
||||
variable "copilot_model" {
|
||||
type = string
|
||||
description = "Model to use. Supported values: claude-sonnet-4, claude-sonnet-4.5 (default), gpt-5."
|
||||
description = "The model to use for Copilot. Any model supported by GitHub Copilot can be used."
|
||||
default = "claude-sonnet-4.5"
|
||||
validation {
|
||||
condition = contains(["claude-sonnet-4", "claude-sonnet-4.5", "gpt-5"], var.copilot_model)
|
||||
error_message = "copilot_model must be one of: claude-sonnet-4, claude-sonnet-4.5, gpt-5."
|
||||
}
|
||||
}
|
||||
|
||||
variable "copilot_config" {
|
||||
|
||||
@@ -13,7 +13,7 @@ Run [OpenCode](https://opencode.ai) AI coding assistant in your workspace for in
|
||||
```tf
|
||||
module "opencode" {
|
||||
source = "registry.coder.com/coder-labs/opencode/coder"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
}
|
||||
@@ -34,7 +34,7 @@ resource "coder_ai_task" "task" {
|
||||
|
||||
module "opencode" {
|
||||
source = "registry.coder.com/coder-labs/opencode/coder"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
|
||||
@@ -89,7 +89,7 @@ Run OpenCode as a command-line tool without web interface or task reporting:
|
||||
```tf
|
||||
module "opencode" {
|
||||
source = "registry.coder.com/coder-labs/opencode/coder"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder"
|
||||
report_tasks = false
|
||||
|
||||
@@ -39,7 +39,7 @@ install_opencode() {
|
||||
if [ "$ARG_OPENCODE_VERSION" = "latest" ]; then
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
else
|
||||
VERSION=$ARG_OPENCODE_VERSION curl -fsSL https://opencode.ai/install | bash
|
||||
curl -fsSL https://opencode.ai/install | VERSION="${ARG_OPENCODE_VERSION}" bash
|
||||
fi
|
||||
export PATH=/home/coder/.opencode/bin:$PATH
|
||||
printf "Opencode location: %s\n" "$(which opencode)"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
display_name: AgentAPI
|
||||
description: Building block for modules that need to run an AgentAPI server
|
||||
description: Building block for modules that need to run an AgentAPI server.
|
||||
icon: ../../../../.icons/coder.svg
|
||||
verified: true
|
||||
tags: [internal, library]
|
||||
@@ -11,65 +11,47 @@ tags: [internal, library]
|
||||
> [!CAUTION]
|
||||
> We do not recommend using this module directly. Instead, please consider using one of our [Tasks-compatible AI agent modules](https://registry.coder.com/modules?search=tag%3Atasks).
|
||||
|
||||
The AgentAPI module is a building block for modules that need to run an AgentAPI server. It is intended primarily for internal use by Coder to create modules compatible with Tasks.
|
||||
The AgentAPI module is a building block for modules that need to run an [AgentAPI](https://github.com/coder/agentapi) server. It is intended primarily for internal use by Coder to create modules compatible with [Tasks](https://coder.com/docs/ai-coder/tasks).
|
||||
|
||||
```tf
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "2.3.0"
|
||||
version = "2.5.0"
|
||||
|
||||
agent_id = var.agent_id
|
||||
web_app_slug = local.app_slug
|
||||
web_app_order = var.order
|
||||
web_app_group = var.group
|
||||
web_app_icon = var.icon
|
||||
web_app_display_name = "Goose"
|
||||
cli_app_slug = "goose-cli"
|
||||
cli_app_display_name = "Goose CLI"
|
||||
module_dir_name = local.module_dir_name
|
||||
cli_app_slug = "goose-cli"
|
||||
module_directory = local.module_directory
|
||||
install_agentapi = var.install_agentapi
|
||||
pre_install_script = var.pre_install_script
|
||||
post_install_script = var.post_install_script
|
||||
start_script = local.start_script
|
||||
install_script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
|
||||
echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh
|
||||
chmod +x /tmp/install.sh
|
||||
|
||||
ARG_PROVIDER='${var.goose_provider}' \
|
||||
ARG_MODEL='${var.goose_model}' \
|
||||
ARG_GOOSE_CONFIG="$(echo -n '${base64encode(local.combined_extensions)}' | base64 -d)" \
|
||||
ARG_INSTALL='${var.install_goose}' \
|
||||
ARG_GOOSE_VERSION='${var.goose_version}' \
|
||||
/tmp/install.sh
|
||||
EOT
|
||||
}
|
||||
```
|
||||
|
||||
## Task log snapshot
|
||||
## Features
|
||||
|
||||
Captures the last 10 messages from AgentAPI when a task workspace stops. This allows viewing conversation history while the task is paused.
|
||||
- **Web and CLI apps**: creates `coder_app` resources for browser-based chat and terminal attachment
|
||||
- **Task log snapshot**: captures the last 10 conversation messages when a workspace stops, enabling offline viewing while the task is paused
|
||||
- **State persistence**: optionally saves and restores AgentAPI conversation state across workspace restarts (requires agentapi >= v0.12.0)
|
||||
- **Script orchestration**: uses [coder-utils](https://registry.coder.com/modules/coder/coder-utils) for `coder exp sync` based script ordering so downstream modules can serialize their own scripts behind this module
|
||||
|
||||
To enable for task workspaces:
|
||||
## Examples
|
||||
|
||||
### Task log snapshot
|
||||
|
||||
Enabled by default. Captures the last 10 messages from AgentAPI when a task workspace stops.
|
||||
|
||||
```tf
|
||||
module "agentapi" {
|
||||
# ... other config
|
||||
task_log_snapshot = true # default: true
|
||||
task_log_snapshot = true # default
|
||||
}
|
||||
```
|
||||
|
||||
## State Persistence
|
||||
### State persistence
|
||||
|
||||
AgentAPI can save and restore conversation state across workspace restarts.
|
||||
This is disabled by default and requires agentapi binary >= v0.12.0.
|
||||
|
||||
State and PID files are stored in `$HOME/<module_dir_name>/` alongside other module files (e.g. `$HOME/.claude-module/agentapi-state.json`).
|
||||
|
||||
To enable:
|
||||
Disabled by default. Requires agentapi >= v0.12.0.
|
||||
|
||||
```tf
|
||||
module "agentapi" {
|
||||
@@ -78,57 +60,38 @@ module "agentapi" {
|
||||
}
|
||||
```
|
||||
|
||||
To override file paths:
|
||||
Custom file paths:
|
||||
|
||||
```tf
|
||||
module "agentapi" {
|
||||
# ... other config
|
||||
state_file_path = "/custom/path/state.json"
|
||||
pid_file_path = "/custom/path/agentapi.pid"
|
||||
enable_state_persistence = true
|
||||
state_file_path = "/custom/path/state.json"
|
||||
pid_file_path = "/custom/path/agentapi.pid"
|
||||
}
|
||||
```
|
||||
|
||||
## Boundary (Network Filtering)
|
||||
### Script serialization
|
||||
|
||||
The agentapi module supports optional [Agent Boundaries](https://coder.com/docs/ai-coder/agent-boundaries)
|
||||
for network filtering. When enabled, the module sets up a `AGENTAPI_BOUNDARY_PREFIX` environment
|
||||
variable that points to a wrapper script. Agent modules should use this prefix in their
|
||||
start scripts to run the agent process through boundary.
|
||||
|
||||
Boundary requires a `config.yaml` file with your allowlist, jail type, proxy port, and log
|
||||
level. See the [Agent Boundaries documentation](https://coder.com/docs/ai-coder/agent-boundaries)
|
||||
for configuration details.
|
||||
To enable:
|
||||
The module outputs `scripts`, an ordered list of `coder exp sync` names. Downstream modules can use these to serialize their own `coder_script` resources behind the install pipeline:
|
||||
|
||||
```tf
|
||||
module "agentapi" {
|
||||
# ... other config
|
||||
enable_boundary = true
|
||||
boundary_config_path = "/home/coder/.config/coder_boundary/config.yaml"
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
# ...
|
||||
}
|
||||
|
||||
# Optional: install boundary binary instead of using coder subcommand
|
||||
# use_boundary_directly = true
|
||||
# boundary_version = "0.6.0"
|
||||
# compile_boundary_from_source = false
|
||||
output "scripts" {
|
||||
value = module.agentapi.scripts
|
||||
}
|
||||
```
|
||||
|
||||
### Contract for agent modules
|
||||
|
||||
When `enable_boundary = true`, the agentapi module exports `AGENTAPI_BOUNDARY_PREFIX`
|
||||
as an environment variable pointing to a wrapper script. Agent module start scripts
|
||||
should check for this variable and use it to prefix the agent command:
|
||||
|
||||
```bash
|
||||
if [ -n "${AGENTAPI_BOUNDARY_PREFIX:-}" ]; then
|
||||
agentapi server -- "${AGENTAPI_BOUNDARY_PREFIX}" my-agent "${ARGS[@]}" &
|
||||
else
|
||||
agentapi server -- my-agent "${ARGS[@]}" &
|
||||
fi
|
||||
```
|
||||
|
||||
This ensures only the agent process is sandboxed while agentapi itself runs unrestricted.
|
||||
|
||||
## For module developers
|
||||
|
||||
For a complete example of how to use this module, see the [Goose module](https://github.com/coder/registry/blob/main/registry/coder/modules/goose/main.tf).
|
||||
For a complete example of how to build a module on top of AgentAPI, see the [Goose module](https://github.com/coder/registry/blob/main/registry/coder/modules/goose/main.tf).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Install logs are written to `~/.coder-modules/coder/agentapi/logs/install.log`
|
||||
- AgentAPI server logs are written to `~/.coder-modules/coder/agentapi/agentapi-start.log`
|
||||
- Check `agentapi --version` to verify the installed binary version
|
||||
|
||||
@@ -7,8 +7,6 @@ variables {
|
||||
web_app_slug = "test"
|
||||
cli_app_display_name = "Test CLI"
|
||||
cli_app_slug = "test-cli"
|
||||
start_script = "echo test"
|
||||
module_dir_name = ".test-module"
|
||||
}
|
||||
|
||||
run "default_values" {
|
||||
@@ -29,33 +27,12 @@ run "default_values" {
|
||||
error_message = "pid_file_path should default to empty string"
|
||||
}
|
||||
|
||||
# Verify start script contains state persistence ARG_ vars.
|
||||
assert {
|
||||
condition = can(regex("ARG_ENABLE_STATE_PERSISTENCE", coder_script.agentapi.script))
|
||||
error_message = "start script should contain ARG_ENABLE_STATE_PERSISTENCE"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = can(regex("ARG_STATE_FILE_PATH", coder_script.agentapi.script))
|
||||
error_message = "start script should contain ARG_STATE_FILE_PATH"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = can(regex("ARG_PID_FILE_PATH", coder_script.agentapi.script))
|
||||
error_message = "start script should contain ARG_PID_FILE_PATH"
|
||||
}
|
||||
|
||||
# Verify shutdown script contains PID-related ARG_ vars.
|
||||
assert {
|
||||
condition = can(regex("ARG_PID_FILE_PATH", coder_script.agentapi_shutdown.script))
|
||||
error_message = "shutdown script should contain ARG_PID_FILE_PATH"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = can(regex("ARG_MODULE_DIR_NAME", coder_script.agentapi_shutdown.script))
|
||||
error_message = "shutdown script should contain ARG_MODULE_DIR_NAME"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = can(regex("ARG_ENABLE_STATE_PERSISTENCE", coder_script.agentapi_shutdown.script))
|
||||
error_message = "shutdown script should contain ARG_ENABLE_STATE_PERSISTENCE"
|
||||
@@ -74,11 +51,10 @@ run "state_persistence_disabled" {
|
||||
error_message = "enable_state_persistence should be false"
|
||||
}
|
||||
|
||||
# Even when disabled, the ARG_ vars should still be in the script
|
||||
# (the shell script handles the conditional logic).
|
||||
# Verify shutdown script contains the disabled flag.
|
||||
assert {
|
||||
condition = can(regex("ARG_ENABLE_STATE_PERSISTENCE='false'", coder_script.agentapi.script))
|
||||
error_message = "start script should contain ARG_ENABLE_STATE_PERSISTENCE='false'"
|
||||
condition = can(regex("ARG_ENABLE_STATE_PERSISTENCE='false'", coder_script.agentapi_shutdown.script))
|
||||
error_message = "shutdown script should contain ARG_ENABLE_STATE_PERSISTENCE='false'"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,19 +66,18 @@ run "custom_paths" {
|
||||
pid_file_path = "/custom/agentapi.pid"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = can(regex("/custom/state.json", coder_script.agentapi.script))
|
||||
error_message = "start script should contain custom state_file_path"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = can(regex("/custom/agentapi.pid", coder_script.agentapi.script))
|
||||
error_message = "start script should contain custom pid_file_path"
|
||||
}
|
||||
|
||||
# Verify custom paths also appear in shutdown script.
|
||||
# Verify custom paths appear in shutdown script.
|
||||
assert {
|
||||
condition = can(regex("/custom/agentapi.pid", coder_script.agentapi_shutdown.script))
|
||||
error_message = "shutdown script should contain custom pid_file_path"
|
||||
}
|
||||
}
|
||||
|
||||
run "scripts_output" {
|
||||
command = plan
|
||||
|
||||
assert {
|
||||
condition = length(output.scripts) == 1 && output.scripts[0] == "coder-agentapi-install_script"
|
||||
error_message = "scripts output should list the install script sync name"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ interface SetupProps {
|
||||
moduleVariables?: Record<string, string>;
|
||||
}
|
||||
|
||||
const moduleDirName = ".agentapi-module";
|
||||
const moduleDirectory = "/home/coder/.agentapi-module";
|
||||
|
||||
const setup = async (props?: SetupProps): Promise<{ id: string }> => {
|
||||
const projectDir = "/home/coder/project";
|
||||
@@ -58,8 +58,7 @@ const setup = async (props?: SetupProps): Promise<{ id: string }> => {
|
||||
cli_app_display_name: "AgentAPI CLI",
|
||||
cli_app_slug: "agentapi-cli",
|
||||
agentapi_version: "latest",
|
||||
module_dir_name: moduleDirName,
|
||||
start_script: await loadTestFile(import.meta.dir, "agentapi-start.sh"),
|
||||
module_directory: moduleDirectory,
|
||||
folder: projectDir,
|
||||
...props?.moduleVariables,
|
||||
},
|
||||
@@ -68,11 +67,31 @@ const setup = async (props?: SetupProps): Promise<{ id: string }> => {
|
||||
skipAgentAPIMock: props?.skipAgentAPIMock,
|
||||
moduleDir: import.meta.dir,
|
||||
});
|
||||
// Mock `coder` CLI so `coder exp sync` calls from coder-utils wrappers
|
||||
// succeed without a real control plane.
|
||||
await writeExecutable({
|
||||
containerId: id,
|
||||
filePath: "/usr/bin/coder",
|
||||
content: "#!/bin/bash\nexit 0\n",
|
||||
});
|
||||
await writeExecutable({
|
||||
containerId: id,
|
||||
filePath: "/usr/bin/aiagent",
|
||||
content: await loadTestFile(import.meta.dir, "ai-agent-mock.js"),
|
||||
});
|
||||
// Write the test start script directly to the module scripts dir,
|
||||
// since start_script is no longer a Terraform variable.
|
||||
const startScript = await loadTestFile(import.meta.dir, "agentapi-start.sh");
|
||||
await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`mkdir -p ${moduleDirectory}/scripts`,
|
||||
]);
|
||||
await writeExecutable({
|
||||
containerId: id,
|
||||
filePath: `${moduleDirectory}/scripts/agentapi-start.sh`,
|
||||
content: startScript,
|
||||
});
|
||||
return { id };
|
||||
};
|
||||
|
||||
@@ -104,36 +123,6 @@ describe("agentapi", async () => {
|
||||
await expectAgentAPIStarted(id, 3827);
|
||||
});
|
||||
|
||||
test("pre-post-install-scripts", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
pre_install_script: `#!/bin/bash\necho "pre-install"`,
|
||||
install_script: `#!/bin/bash\necho "install"`,
|
||||
post_install_script: `#!/bin/bash\necho "post-install"`,
|
||||
},
|
||||
});
|
||||
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
|
||||
const preInstallLog = await readFileContainer(
|
||||
id,
|
||||
`/home/coder/${moduleDirName}/pre_install.log`,
|
||||
);
|
||||
const installLog = await readFileContainer(
|
||||
id,
|
||||
`/home/coder/${moduleDirName}/install.log`,
|
||||
);
|
||||
const postInstallLog = await readFileContainer(
|
||||
id,
|
||||
`/home/coder/${moduleDirName}/post_install.log`,
|
||||
);
|
||||
|
||||
expect(preInstallLog).toContain("pre-install");
|
||||
expect(installLog).toContain("install");
|
||||
expect(postInstallLog).toContain("post-install");
|
||||
});
|
||||
|
||||
test("install-agentapi", async () => {
|
||||
const { id } = await setup({ skipAgentAPIMock: true });
|
||||
|
||||
@@ -313,10 +302,10 @@ describe("agentapi", async () => {
|
||||
"/home/coder/agentapi-mock.log",
|
||||
);
|
||||
expect(mockLog).toContain(
|
||||
`AGENTAPI_STATE_FILE: /home/coder/${moduleDirName}/agentapi-state.json`,
|
||||
`AGENTAPI_STATE_FILE: ${moduleDirectory}/agentapi-state.json`,
|
||||
);
|
||||
expect(mockLog).toContain(
|
||||
`AGENTAPI_PID_FILE: /home/coder/${moduleDirName}/agentapi.pid`,
|
||||
`AGENTAPI_PID_FILE: ${moduleDirectory}/agentapi.pid`,
|
||||
);
|
||||
expect(mockLog).toContain("AGENTAPI_SAVE_STATE: true");
|
||||
expect(mockLog).toContain("AGENTAPI_LOAD_STATE: true");
|
||||
@@ -397,7 +386,7 @@ describe("agentapi", async () => {
|
||||
return await execContainer(containerId, [
|
||||
"bash",
|
||||
"-c",
|
||||
`ARG_TASK_ID=${taskId} ARG_AGENTAPI_PORT=3284 ARG_PID_FILE_PATH=${pidFilePath} ARG_ENABLE_STATE_PERSISTENCE=${enableStatePersistence} CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`,
|
||||
`ARG_TASK_ID=${taskId} ARG_AGENTAPI_PORT=3284 ARG_PID_FILE_PATH=${pidFilePath} ARG_ENABLE_STATE_PERSISTENCE=${enableStatePersistence} ARG_LIB_SCRIPT_PATH=/tmp/agentapi-lib.sh CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -542,15 +531,15 @@ describe("agentapi", async () => {
|
||||
expect(result.stdout).toContain("Sending SIGTERM to AgentAPI");
|
||||
});
|
||||
|
||||
test("resolves default PID path from MODULE_DIR_NAME", async () => {
|
||||
test("resolves default PID path from MODULE_DIRECTORY", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {},
|
||||
skipAgentAPIMock: true,
|
||||
});
|
||||
// Start mock with PID file at the module_dir_name default location.
|
||||
const defaultPidPath = `/home/coder/${moduleDirName}/agentapi.pid`;
|
||||
// Start mock with PID file at the module_directory default location.
|
||||
const defaultPidPath = `${moduleDirectory}/agentapi.pid`;
|
||||
await setupMocks(id, "normal", 204, defaultPidPath);
|
||||
// Don't pass pidFilePath - let shutdown script compute it from MODULE_DIR_NAME.
|
||||
// Don't pass pidFilePath - let shutdown script compute it from MODULE_DIRECTORY.
|
||||
const shutdownScript = await loadTestFile(
|
||||
import.meta.dir,
|
||||
"../scripts/agentapi-shutdown.sh",
|
||||
@@ -572,7 +561,7 @@ describe("agentapi", async () => {
|
||||
const result = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`ARG_TASK_ID=test-task ARG_AGENTAPI_PORT=3284 ARG_MODULE_DIR_NAME=${moduleDirName} ARG_ENABLE_STATE_PERSISTENCE=true CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`,
|
||||
`ARG_TASK_ID=test-task ARG_AGENTAPI_PORT=3284 ARG_MODULE_DIRECTORY=${moduleDirectory} ARG_ENABLE_STATE_PERSISTENCE=true ARG_LIB_SCRIPT_PATH=/tmp/agentapi-lib.sh CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`,
|
||||
]);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
@@ -586,7 +575,7 @@ describe("agentapi", async () => {
|
||||
skipAgentAPIMock: true,
|
||||
});
|
||||
await setupMocks(id, "normal", 204);
|
||||
// No pidFilePath and no MODULE_DIR_NAME, so no PID file can be resolved.
|
||||
// No pidFilePath and no MODULE_DIRECTORY, so no PID file can be resolved.
|
||||
const result = await runShutdownScript(id, "test-task", "", "false");
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
@@ -613,109 +602,4 @@ describe("agentapi", async () => {
|
||||
expect(result.stdout).toContain("Sending SIGTERM to AgentAPI");
|
||||
});
|
||||
});
|
||||
|
||||
describe("boundary", async () => {
|
||||
test("boundary-disabled-by-default", async () => {
|
||||
const { id } = await setup();
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
// Config file should NOT exist when boundary is disabled
|
||||
const configCheck = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"test -f /home/coder/.config/coder_boundary/config.yaml && echo exists || echo missing",
|
||||
]);
|
||||
expect(configCheck.stdout.trim()).toBe("missing");
|
||||
// AGENTAPI_BOUNDARY_PREFIX should NOT be in the mock log
|
||||
const mockLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/agentapi-mock.log",
|
||||
);
|
||||
expect(mockLog).not.toContain("AGENTAPI_BOUNDARY_PREFIX:");
|
||||
});
|
||||
|
||||
test("boundary-enabled", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
enable_boundary: "true",
|
||||
boundary_config_path: "/tmp/test-boundary.yaml",
|
||||
},
|
||||
});
|
||||
// Write boundary config to the path before running the module
|
||||
await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`cat > /tmp/test-boundary.yaml <<'EOF'
|
||||
jail_type: landjail
|
||||
proxy_port: 8087
|
||||
log_level: warn
|
||||
allowlist:
|
||||
- "domain=api.example.com"
|
||||
EOF`,
|
||||
]);
|
||||
// Add mock coder binary for boundary setup
|
||||
await writeExecutable({
|
||||
containerId: id,
|
||||
filePath: "/usr/bin/coder",
|
||||
content: `#!/bin/bash
|
||||
if [ "$1" = "boundary" ]; then
|
||||
shift; shift; exec "$@"
|
||||
fi
|
||||
echo "mock coder"`,
|
||||
});
|
||||
await execModuleScript(id);
|
||||
await expectAgentAPIStarted(id);
|
||||
// Verify the config file exists at the specified path
|
||||
const config = await readFileContainer(id, "/tmp/test-boundary.yaml");
|
||||
expect(config).toContain("jail_type: landjail");
|
||||
expect(config).toContain("proxy_port: 8087");
|
||||
expect(config).toContain("domain=api.example.com");
|
||||
// AGENTAPI_BOUNDARY_PREFIX should be exported
|
||||
const mockLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/agentapi-mock.log",
|
||||
);
|
||||
expect(mockLog).toContain("AGENTAPI_BOUNDARY_PREFIX:");
|
||||
// E2E: start script should have used the wrapper
|
||||
const startLog = await readFileContainer(
|
||||
id,
|
||||
"/home/coder/test-agentapi-start.log",
|
||||
);
|
||||
expect(startLog).toContain("Starting with boundary:");
|
||||
});
|
||||
|
||||
test("boundary-enabled-no-coder-binary", async () => {
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
enable_boundary: "true",
|
||||
boundary_config_path: "/tmp/test-boundary.yaml",
|
||||
},
|
||||
});
|
||||
// Write boundary config
|
||||
await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
`cat > /tmp/test-boundary.yaml <<'EOF'
|
||||
jail_type: landjail
|
||||
proxy_port: 8087
|
||||
log_level: warn
|
||||
EOF`,
|
||||
]);
|
||||
// Remove coder binary to simulate it not being available
|
||||
await execContainer(
|
||||
id,
|
||||
[
|
||||
"bash",
|
||||
"-c",
|
||||
"rm -f /usr/bin/coder /usr/local/bin/coder 2>/dev/null; hash -r",
|
||||
],
|
||||
["--user", "root"],
|
||||
);
|
||||
const resp = await execModuleScript(id);
|
||||
// Script should fail because coder binary is required
|
||||
expect(resp.exitCode).not.toBe(0);
|
||||
const scriptLog = await readFileContainer(id, "/home/coder/script.log");
|
||||
expect(scriptLog).toContain("Boundary cannot be enabled");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,6 +53,12 @@ variable "folder" {
|
||||
default = "/home/coder"
|
||||
}
|
||||
|
||||
variable "web_app" {
|
||||
type = bool
|
||||
description = "Whether to create the web workspace app. This is automatically enabled when using Coder Tasks, regardless of this setting."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "cli_app" {
|
||||
type = bool
|
||||
description = "Whether to create the CLI workspace app."
|
||||
@@ -87,29 +93,6 @@ variable "cli_app_slug" {
|
||||
description = "The slug of the CLI workspace app."
|
||||
}
|
||||
|
||||
variable "pre_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run before installing the agent used by AgentAPI."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "install_script" {
|
||||
type = string
|
||||
description = "Script to install the agent used by AgentAPI."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "post_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run after installing the agent used by AgentAPI."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "start_script" {
|
||||
type = string
|
||||
description = "Script that starts AgentAPI."
|
||||
}
|
||||
|
||||
variable "install_agentapi" {
|
||||
type = bool
|
||||
description = "Whether to install AgentAPI."
|
||||
@@ -159,41 +142,6 @@ variable "agentapi_subdomain" {
|
||||
}
|
||||
}
|
||||
|
||||
variable "module_dir_name" {
|
||||
type = string
|
||||
description = "Name of the subdirectory in the home directory for module files."
|
||||
}
|
||||
|
||||
variable "enable_boundary" {
|
||||
type = bool
|
||||
description = "Enable coder boundary for network filtering. Requires boundary_config to be set."
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "boundary_config_path" {
|
||||
type = string
|
||||
description = "Path to boundary config.yaml inside the workspace. If provided, exposed as BOUNDARY_CONFIG env var."
|
||||
default = ""
|
||||
}
|
||||
|
||||
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 "enable_state_persistence" {
|
||||
type = bool
|
||||
description = "Enable AgentAPI conversation state persistence across restarts."
|
||||
@@ -202,31 +150,30 @@ variable "enable_state_persistence" {
|
||||
|
||||
variable "state_file_path" {
|
||||
type = string
|
||||
description = "Path to the AgentAPI state file. Defaults to $HOME/<module_dir_name>/agentapi-state.json."
|
||||
description = "Path to the AgentAPI state file. Defaults to <module_directory>/agentapi-state.json."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "pid_file_path" {
|
||||
type = string
|
||||
description = "Path to the AgentAPI PID file. Defaults to $HOME/<module_dir_name>/agentapi.pid."
|
||||
description = "Path to the AgentAPI PID file. Defaults to <module_directory>/agentapi.pid."
|
||||
default = ""
|
||||
}
|
||||
|
||||
resource "coder_env" "boundary_config" {
|
||||
count = var.enable_boundary && var.boundary_config_path != "" ? 1 : 0
|
||||
agent_id = var.agent_id
|
||||
name = "BOUNDARY_CONFIG"
|
||||
value = var.boundary_config_path
|
||||
variable "module_directory" {
|
||||
type = string
|
||||
description = ""
|
||||
default = "$HOME/.coder-modules/coder/agentapi"
|
||||
}
|
||||
|
||||
locals {
|
||||
# If this is a Task, always create the web app regardless of var.web_app
|
||||
# since coder_ai_task requires the app to function.
|
||||
is_task = try(data.coder_task.me.enabled, false)
|
||||
web_app = var.web_app || local.is_task
|
||||
|
||||
# we always trim the slash for consistency
|
||||
workdir = trimsuffix(var.folder, "/")
|
||||
encoded_pre_install_script = var.pre_install_script != null ? base64encode(var.pre_install_script) : ""
|
||||
encoded_install_script = var.install_script != null ? base64encode(var.install_script) : ""
|
||||
encoded_post_install_script = var.post_install_script != null ? base64encode(var.post_install_script) : ""
|
||||
agentapi_start_script_b64 = base64encode(var.start_script)
|
||||
agentapi_wait_for_start_script_b64 = base64encode(file("${path.module}/scripts/agentapi-wait-for-start.sh"))
|
||||
workdir = trimsuffix(var.folder, "/")
|
||||
// Chat base path is only set if not using a subdomain.
|
||||
// NOTE:
|
||||
// - Initial support for --chat-base-path was added in v0.3.1 but configuration
|
||||
@@ -234,51 +181,38 @@ locals {
|
||||
// - As CODER_WORKSPACE_AGENT_NAME is a recent addition we use agent ID
|
||||
// for backward compatibility.
|
||||
agentapi_chat_base_path = var.agentapi_subdomain ? "" : "/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}.${var.agent_id}/apps/${var.web_app_slug}/chat"
|
||||
main_script = file("${path.module}/scripts/main.sh")
|
||||
shutdown_script = file("${path.module}/scripts/agentapi-shutdown.sh")
|
||||
lib_script = file("${path.module}/scripts/lib.sh")
|
||||
boundary_script = file("${path.module}/scripts/boundary.sh")
|
||||
|
||||
shutdown_script_destination = "${var.module_directory}/agentapi-shutdown.sh"
|
||||
lib_script_destination = "${var.module_directory}/agentapi-lib.sh"
|
||||
|
||||
install_script = templatefile("${path.module}/scripts/install.sh.tftpl", {
|
||||
ARG_MODULE_DIRECTORY = var.module_directory
|
||||
ARG_WORKDIR = local.workdir
|
||||
ARG_INSTALL_AGENTAPI = tostring(var.install_agentapi)
|
||||
ARG_AGENTAPI_VERSION = var.agentapi_version
|
||||
ARG_WAIT_FOR_START_SCRIPT = base64encode(file("${path.module}/scripts/agentapi-wait-for-start.sh"))
|
||||
ARG_AGENTAPI_PORT = tostring(var.agentapi_port)
|
||||
ARG_AGENTAPI_CHAT_BASE_PATH = local.agentapi_chat_base_path
|
||||
ARG_TASK_ID = try(data.coder_task.me.id, "")
|
||||
ARG_TASK_LOG_SNAPSHOT = tostring(var.task_log_snapshot)
|
||||
ARG_ENABLE_STATE_PERSISTENCE = tostring(var.enable_state_persistence)
|
||||
ARG_STATE_FILE_PATH = var.state_file_path
|
||||
ARG_PID_FILE_PATH = var.pid_file_path
|
||||
ARG_LIB_SCRIPT = base64encode(local.lib_script)
|
||||
})
|
||||
}
|
||||
|
||||
resource "coder_script" "agentapi" {
|
||||
agent_id = var.agent_id
|
||||
display_name = "Install and start AgentAPI"
|
||||
icon = var.web_app_icon
|
||||
script = <<-EOT
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
module "coder_utils" {
|
||||
source = "registry.coder.com/coder/coder-utils/coder"
|
||||
version = "0.0.1"
|
||||
|
||||
echo -n '${base64encode(local.main_script)}' | base64 -d > /tmp/main.sh
|
||||
chmod +x /tmp/main.sh
|
||||
echo -n '${base64encode(local.lib_script)}' | base64 -d > /tmp/agentapi-lib.sh
|
||||
|
||||
echo -n '${base64encode(local.boundary_script)}' | base64 -d > /tmp/agentapi-boundary.sh
|
||||
chmod +x /tmp/agentapi-boundary.sh
|
||||
|
||||
ARG_MODULE_DIR_NAME='${var.module_dir_name}' \
|
||||
ARG_WORKDIR="$(echo -n '${base64encode(local.workdir)}' | base64 -d)" \
|
||||
ARG_PRE_INSTALL_SCRIPT="$(echo -n '${local.encoded_pre_install_script}' | base64 -d)" \
|
||||
ARG_INSTALL_SCRIPT="$(echo -n '${local.encoded_install_script}' | base64 -d)" \
|
||||
ARG_INSTALL_AGENTAPI='${var.install_agentapi}' \
|
||||
ARG_AGENTAPI_VERSION='${var.agentapi_version}' \
|
||||
ARG_START_SCRIPT="$(echo -n '${local.agentapi_start_script_b64}' | base64 -d)" \
|
||||
ARG_WAIT_FOR_START_SCRIPT="$(echo -n '${local.agentapi_wait_for_start_script_b64}' | base64 -d)" \
|
||||
ARG_POST_INSTALL_SCRIPT="$(echo -n '${local.encoded_post_install_script}' | base64 -d)" \
|
||||
ARG_AGENTAPI_PORT='${var.agentapi_port}' \
|
||||
ARG_AGENTAPI_CHAT_BASE_PATH='${local.agentapi_chat_base_path}' \
|
||||
ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \
|
||||
ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \
|
||||
ARG_ENABLE_BOUNDARY='${var.enable_boundary}' \
|
||||
ARG_BOUNDARY_VERSION='${var.boundary_version}' \
|
||||
ARG_COMPILE_BOUNDARY_FROM_SOURCE='${var.compile_boundary_from_source}' \
|
||||
ARG_USE_BOUNDARY_DIRECTLY='${var.use_boundary_directly}' \
|
||||
ARG_ENABLE_STATE_PERSISTENCE='${var.enable_state_persistence}' \
|
||||
ARG_STATE_FILE_PATH='${var.state_file_path}' \
|
||||
ARG_PID_FILE_PATH='${var.pid_file_path}' \
|
||||
/tmp/main.sh
|
||||
EOT
|
||||
run_on_start = true
|
||||
agent_id = var.agent_id
|
||||
module_directory = var.module_directory
|
||||
display_name_prefix = "AgentAPI"
|
||||
icon = var.web_app_icon
|
||||
install_script = local.install_script
|
||||
}
|
||||
|
||||
resource "coder_script" "agentapi_shutdown" {
|
||||
@@ -290,21 +224,25 @@ resource "coder_script" "agentapi_shutdown" {
|
||||
#!/bin/bash
|
||||
set -o pipefail
|
||||
|
||||
echo -n '${base64encode(local.shutdown_script)}' | base64 -d > /tmp/agentapi-shutdown.sh
|
||||
chmod +x /tmp/agentapi-shutdown.sh
|
||||
echo -n '${base64encode(local.lib_script)}' | base64 -d > /tmp/agentapi-lib.sh
|
||||
mkdir -p "${var.module_directory}"
|
||||
echo -n '${base64encode(local.shutdown_script)}' | base64 -d > "${local.shutdown_script_destination}"
|
||||
chmod +x "${local.shutdown_script_destination}"
|
||||
echo -n '${base64encode(local.lib_script)}' | base64 -d > "${local.lib_script_destination}"
|
||||
|
||||
ARG_MODULE_DIRECTORY='${var.module_directory}' \
|
||||
ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \
|
||||
ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \
|
||||
ARG_AGENTAPI_PORT='${var.agentapi_port}' \
|
||||
ARG_ENABLE_STATE_PERSISTENCE='${var.enable_state_persistence}' \
|
||||
ARG_MODULE_DIR_NAME='${var.module_dir_name}' \
|
||||
ARG_PID_FILE_PATH='${var.pid_file_path}' \
|
||||
/tmp/agentapi-shutdown.sh
|
||||
ARG_LIB_SCRIPT_PATH="${local.lib_script_destination}" \
|
||||
"${local.shutdown_script_destination}"
|
||||
EOT
|
||||
}
|
||||
|
||||
resource "coder_app" "agentapi_web" {
|
||||
count = local.web_app ? 1 : 0
|
||||
|
||||
slug = var.web_app_slug
|
||||
display_name = var.web_app_display_name
|
||||
agent_id = var.agent_id
|
||||
@@ -341,5 +279,10 @@ resource "coder_app" "agentapi_cli" {
|
||||
}
|
||||
|
||||
output "task_app_id" {
|
||||
value = coder_app.agentapi_web.id
|
||||
value = local.web_app ? coder_app.agentapi_web[0].id : ""
|
||||
}
|
||||
|
||||
output "scripts" {
|
||||
description = "Ordered list of coder exp sync names for the coder_script resources this module creates, in run order. Scripts that were not configured are absent from the list."
|
||||
value = module.coder_utils.scripts
|
||||
}
|
||||
|
||||
@@ -12,12 +12,13 @@ readonly TASK_ID="${ARG_TASK_ID:-}"
|
||||
readonly TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}"
|
||||
readonly AGENTAPI_PORT="${ARG_AGENTAPI_PORT:-3284}"
|
||||
readonly ENABLE_STATE_PERSISTENCE="${ARG_ENABLE_STATE_PERSISTENCE:-false}"
|
||||
readonly MODULE_DIR_NAME="${ARG_MODULE_DIR_NAME:-}"
|
||||
readonly PID_FILE_PATH="${ARG_PID_FILE_PATH:-${MODULE_DIR_NAME:+$HOME/$MODULE_DIR_NAME/agentapi.pid}}"
|
||||
readonly MODULE_DIRECTORY="${ARG_MODULE_DIRECTORY:-}"
|
||||
readonly PID_FILE_PATH="${ARG_PID_FILE_PATH:-${MODULE_DIRECTORY:+${MODULE_DIRECTORY}/agentapi.pid}}"
|
||||
readonly LIB_SCRIPT_PATH="${ARG_LIB_SCRIPT_PATH}"
|
||||
|
||||
# Source shared utilities (written by the coder_script wrapper).
|
||||
# shellcheck source=lib.sh
|
||||
source /tmp/agentapi-lib.sh
|
||||
source "${LIB_SCRIPT_PATH}"
|
||||
|
||||
# Runtime environment variables.
|
||||
readonly CODER_AGENT_URL="${CODER_AGENT_URL:-}"
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
#!/bin/bash
|
||||
# boundary.sh - Boundary installation and setup for agentapi module.
|
||||
# Sourced by main.sh when ENABLE_BOUNDARY=true.
|
||||
# Exports AGENTAPI_BOUNDARY_PREFIX for use by module start scripts.
|
||||
|
||||
validate_boundary_subcommand() {
|
||||
if command_exists coder; then
|
||||
if coder boundary --help > /dev/null 2>&1; then
|
||||
return 0
|
||||
else
|
||||
echo "Error: 'coder' command found but does not support 'boundary' subcommand. Please enable install_boundary."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Error: ENABLE_BOUNDARY=true, but '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, write config, create wrapper script.
|
||||
# Exports AGENTAPI_BOUNDARY_PREFIX pointing to the wrapper script.
|
||||
setup_boundary() {
|
||||
local module_path="$1"
|
||||
|
||||
echo "Setting up coder boundary..."
|
||||
|
||||
# Install boundary binary if needed
|
||||
install_boundary
|
||||
|
||||
# Determine which boundary command to use and create wrapper script
|
||||
BOUNDARY_WRAPPER_SCRIPT="$module_path/boundary-wrapper.sh"
|
||||
|
||||
if [ "${COMPILE_BOUNDARY_FROM_SOURCE}" = "true" ] || [ "${USE_BOUNDARY_DIRECTLY}" = "true" ]; then
|
||||
# Use boundary binary directly (from compilation or release installation)
|
||||
cat > "${BOUNDARY_WRAPPER_SCRIPT}" << '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_path/coder-no-caps"
|
||||
if ! cp "$(which 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_SCRIPT}" << '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_SCRIPT}"
|
||||
export AGENTAPI_BOUNDARY_PREFIX="${BOUNDARY_WRAPPER_SCRIPT}"
|
||||
echo "Boundary wrapper configured: ${AGENTAPI_BOUNDARY_PREFIX}"
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
set -x
|
||||
|
||||
set -o nounset
|
||||
|
||||
MODULE_DIRECTORY='${ARG_MODULE_DIRECTORY}'
|
||||
WORKDIR='${ARG_WORKDIR}'
|
||||
INSTALL_AGENTAPI='${ARG_INSTALL_AGENTAPI}'
|
||||
AGENTAPI_VERSION='${ARG_AGENTAPI_VERSION}'
|
||||
WAIT_FOR_START_SCRIPT=$(echo -n '${ARG_WAIT_FOR_START_SCRIPT}' | base64 -d)
|
||||
AGENTAPI_PORT='${ARG_AGENTAPI_PORT}'
|
||||
AGENTAPI_CHAT_BASE_PATH='${ARG_AGENTAPI_CHAT_BASE_PATH}'
|
||||
TASK_ID='${ARG_TASK_ID}'
|
||||
TASK_LOG_SNAPSHOT='${ARG_TASK_LOG_SNAPSHOT}'
|
||||
ENABLE_STATE_PERSISTENCE='${ARG_ENABLE_STATE_PERSISTENCE}'
|
||||
STATE_FILE_PATH='${ARG_STATE_FILE_PATH}'
|
||||
PID_FILE_PATH='${ARG_PID_FILE_PATH}'
|
||||
LIB_SCRIPT=$(echo -n '${ARG_LIB_SCRIPT}' | base64 -d)
|
||||
|
||||
set +o nounset
|
||||
|
||||
# Write and source lib.sh
|
||||
LIB_SCRIPT_PATH="$${MODULE_DIRECTORY}/agentapi-lib.sh"
|
||||
echo -n "$LIB_SCRIPT" > "$LIB_SCRIPT_PATH"
|
||||
# shellcheck source=lib.sh
|
||||
source "$LIB_SCRIPT_PATH"
|
||||
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
mkdir -p "$${MODULE_DIRECTORY}/scripts"
|
||||
|
||||
# Check for jq dependency if task log snapshot is enabled.
|
||||
if [[ $TASK_LOG_SNAPSHOT == true ]] && [[ -n $TASK_ID ]]; then
|
||||
if ! command_exists jq; then
|
||||
echo "Warning: jq is not installed. Task log snapshot requires jq to capture conversation history."
|
||||
echo "Install jq to enable log snapshot functionality when the workspace stops."
|
||||
fi
|
||||
fi
|
||||
if [ ! -d "$${WORKDIR}" ]; then
|
||||
echo "Warning: The specified folder '$${WORKDIR}' does not exist."
|
||||
echo "Creating the folder..."
|
||||
mkdir -p "$${WORKDIR}"
|
||||
echo "Folder created successfully."
|
||||
fi
|
||||
# Install AgentAPI if enabled
|
||||
if [ "$${INSTALL_AGENTAPI}" = "true" ]; then
|
||||
echo "Installing AgentAPI..."
|
||||
arch=$(uname -m)
|
||||
if [ "$arch" = "x86_64" ]; then
|
||||
binary_name="agentapi-linux-amd64"
|
||||
elif [ "$arch" = "aarch64" ]; then
|
||||
binary_name="agentapi-linux-arm64"
|
||||
else
|
||||
echo "Error: Unsupported architecture: $arch"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$${AGENTAPI_VERSION}" = "latest" ]; then
|
||||
# for the latest release the download URL pattern is different than for tagged releases
|
||||
# https://docs.github.com/en/repositories/releasing-projects-on-github/linking-to-releases
|
||||
download_url="https://github.com/coder/agentapi/releases/latest/download/$binary_name"
|
||||
else
|
||||
download_url="https://github.com/coder/agentapi/releases/download/$${AGENTAPI_VERSION}/$binary_name"
|
||||
fi
|
||||
curl \
|
||||
--retry 5 \
|
||||
--retry-delay 5 \
|
||||
--fail \
|
||||
--retry-all-errors \
|
||||
-L \
|
||||
-C - \
|
||||
-o agentapi \
|
||||
"$download_url"
|
||||
chmod +x agentapi
|
||||
sudo mv agentapi /usr/local/bin/agentapi
|
||||
fi
|
||||
if ! command_exists agentapi; then
|
||||
echo "Error: AgentAPI is not installed. Please enable install_agentapi or install it manually."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -n "$${WAIT_FOR_START_SCRIPT}" > "$${MODULE_DIRECTORY}/scripts/agentapi-wait-for-start.sh"
|
||||
chmod +x "$${MODULE_DIRECTORY}/scripts/agentapi-wait-for-start.sh"
|
||||
|
||||
export LANG=en_US.UTF-8
|
||||
export LC_ALL=en_US.UTF-8
|
||||
|
||||
cd "$${WORKDIR}"
|
||||
|
||||
export AGENTAPI_CHAT_BASE_PATH="$${AGENTAPI_CHAT_BASE_PATH:-}"
|
||||
# Disable host header check since AgentAPI is proxied by Coder (which does its own validation)
|
||||
export AGENTAPI_ALLOWED_HOSTS="*"
|
||||
|
||||
export AGENTAPI_PID_FILE="$${PID_FILE_PATH:-$${MODULE_DIRECTORY}/agentapi.pid}"
|
||||
# Only set state env vars when persistence is enabled and the binary supports
|
||||
# it. State persistence requires agentapi >= v0.12.0.
|
||||
if [ "$${ENABLE_STATE_PERSISTENCE}" = "true" ]; then
|
||||
actual_version=$(agentapi_version)
|
||||
if version_at_least 0.12.0 "$actual_version"; then
|
||||
export AGENTAPI_STATE_FILE="$${STATE_FILE_PATH:-$${MODULE_DIRECTORY}/agentapi-state.json}"
|
||||
export AGENTAPI_SAVE_STATE="true"
|
||||
export AGENTAPI_LOAD_STATE="true"
|
||||
else
|
||||
echo "Warning: State persistence requires agentapi >= v0.12.0 (current: $${actual_version:-unknown}), skipping."
|
||||
fi
|
||||
fi
|
||||
nohup "$${MODULE_DIRECTORY}/scripts/agentapi-start.sh" true "$${AGENTAPI_PORT}" &> "$${MODULE_DIRECTORY}/agentapi-start.log" &
|
||||
"$${MODULE_DIRECTORY}/scripts/agentapi-wait-for-start.sh" "$${AGENTAPI_PORT}"
|
||||
@@ -1,142 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
set -x
|
||||
|
||||
set -o nounset
|
||||
MODULE_DIR_NAME="$ARG_MODULE_DIR_NAME"
|
||||
WORKDIR="$ARG_WORKDIR"
|
||||
PRE_INSTALL_SCRIPT="$ARG_PRE_INSTALL_SCRIPT"
|
||||
INSTALL_SCRIPT="$ARG_INSTALL_SCRIPT"
|
||||
INSTALL_AGENTAPI="$ARG_INSTALL_AGENTAPI"
|
||||
AGENTAPI_VERSION="$ARG_AGENTAPI_VERSION"
|
||||
START_SCRIPT="$ARG_START_SCRIPT"
|
||||
WAIT_FOR_START_SCRIPT="$ARG_WAIT_FOR_START_SCRIPT"
|
||||
POST_INSTALL_SCRIPT="$ARG_POST_INSTALL_SCRIPT"
|
||||
AGENTAPI_PORT="$ARG_AGENTAPI_PORT"
|
||||
AGENTAPI_CHAT_BASE_PATH="${ARG_AGENTAPI_CHAT_BASE_PATH:-}"
|
||||
TASK_ID="${ARG_TASK_ID:-}"
|
||||
TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}"
|
||||
ENABLE_BOUNDARY="${ARG_ENABLE_BOUNDARY:-false}"
|
||||
BOUNDARY_VERSION="${ARG_BOUNDARY_VERSION:-latest}"
|
||||
COMPILE_BOUNDARY_FROM_SOURCE="${ARG_COMPILE_BOUNDARY_FROM_SOURCE:-false}"
|
||||
USE_BOUNDARY_DIRECTLY="${ARG_USE_BOUNDARY_DIRECTLY:-false}"
|
||||
ENABLE_STATE_PERSISTENCE="${ARG_ENABLE_STATE_PERSISTENCE:-false}"
|
||||
STATE_FILE_PATH="${ARG_STATE_FILE_PATH:-}"
|
||||
PID_FILE_PATH="${ARG_PID_FILE_PATH:-}"
|
||||
set +o nounset
|
||||
|
||||
# shellcheck source=lib.sh
|
||||
source /tmp/agentapi-lib.sh
|
||||
|
||||
command_exists() {
|
||||
command -v "$1" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
module_path="$HOME/${MODULE_DIR_NAME}"
|
||||
mkdir -p "$module_path/scripts"
|
||||
|
||||
# Check for jq dependency if task log snapshot is enabled.
|
||||
if [[ $TASK_LOG_SNAPSHOT == true ]] && [[ -n $TASK_ID ]]; then
|
||||
if ! command_exists jq; then
|
||||
echo "Warning: jq is not installed. Task log snapshot requires jq to capture conversation history."
|
||||
echo "Install jq to enable log snapshot functionality when the workspace stops."
|
||||
fi
|
||||
fi
|
||||
if [ ! -d "${WORKDIR}" ]; then
|
||||
echo "Warning: The specified folder '${WORKDIR}' does not exist."
|
||||
echo "Creating the folder..."
|
||||
mkdir -p "${WORKDIR}"
|
||||
echo "Folder created successfully."
|
||||
fi
|
||||
if [ -n "${PRE_INSTALL_SCRIPT}" ]; then
|
||||
echo "Running pre-install script..."
|
||||
echo -n "${PRE_INSTALL_SCRIPT}" > "$module_path/pre_install.sh"
|
||||
chmod +x "$module_path/pre_install.sh"
|
||||
"$module_path/pre_install.sh" 2>&1 | tee "$module_path/pre_install.log"
|
||||
fi
|
||||
|
||||
echo "Running install script..."
|
||||
echo -n "${INSTALL_SCRIPT}" > "$module_path/install.sh"
|
||||
chmod +x "$module_path/install.sh"
|
||||
"$module_path/install.sh" 2>&1 | tee "$module_path/install.log"
|
||||
|
||||
# Install AgentAPI if enabled
|
||||
if [ "${INSTALL_AGENTAPI}" = "true" ]; then
|
||||
echo "Installing AgentAPI..."
|
||||
arch=$(uname -m)
|
||||
if [ "$arch" = "x86_64" ]; then
|
||||
binary_name="agentapi-linux-amd64"
|
||||
elif [ "$arch" = "aarch64" ]; then
|
||||
binary_name="agentapi-linux-arm64"
|
||||
else
|
||||
echo "Error: Unsupported architecture: $arch"
|
||||
exit 1
|
||||
fi
|
||||
if [ "${AGENTAPI_VERSION}" = "latest" ]; then
|
||||
# for the latest release the download URL pattern is different than for tagged releases
|
||||
# https://docs.github.com/en/repositories/releasing-projects-on-github/linking-to-releases
|
||||
download_url="https://github.com/coder/agentapi/releases/latest/download/$binary_name"
|
||||
else
|
||||
download_url="https://github.com/coder/agentapi/releases/download/${AGENTAPI_VERSION}/$binary_name"
|
||||
fi
|
||||
curl \
|
||||
--retry 5 \
|
||||
--retry-delay 5 \
|
||||
--fail \
|
||||
--retry-all-errors \
|
||||
-L \
|
||||
-C - \
|
||||
-o agentapi \
|
||||
"$download_url"
|
||||
chmod +x agentapi
|
||||
sudo mv agentapi /usr/local/bin/agentapi
|
||||
fi
|
||||
if ! command_exists agentapi; then
|
||||
echo "Error: AgentAPI is not installed. Please enable install_agentapi or install it manually."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -n "${START_SCRIPT}" > "$module_path/scripts/agentapi-start.sh"
|
||||
echo -n "${WAIT_FOR_START_SCRIPT}" > "$module_path/scripts/agentapi-wait-for-start.sh"
|
||||
chmod +x "$module_path/scripts/agentapi-start.sh"
|
||||
chmod +x "$module_path/scripts/agentapi-wait-for-start.sh"
|
||||
|
||||
if [ -n "${POST_INSTALL_SCRIPT}" ]; then
|
||||
echo "Running post-install script..."
|
||||
echo -n "${POST_INSTALL_SCRIPT}" > "$module_path/post_install.sh"
|
||||
chmod +x "$module_path/post_install.sh"
|
||||
"$module_path/post_install.sh" 2>&1 | tee "$module_path/post_install.log"
|
||||
fi
|
||||
|
||||
export LANG=en_US.UTF-8
|
||||
export LC_ALL=en_US.UTF-8
|
||||
|
||||
cd "${WORKDIR}"
|
||||
|
||||
# Set up boundary if enabled
|
||||
export AGENTAPI_BOUNDARY_PREFIX=""
|
||||
if [ "${ENABLE_BOUNDARY}" = "true" ]; then
|
||||
# shellcheck source=boundary.sh
|
||||
source /tmp/agentapi-boundary.sh
|
||||
setup_boundary "$module_path"
|
||||
fi
|
||||
|
||||
export AGENTAPI_CHAT_BASE_PATH="${AGENTAPI_CHAT_BASE_PATH:-}"
|
||||
# Disable host header check since AgentAPI is proxied by Coder (which does its own validation)
|
||||
export AGENTAPI_ALLOWED_HOSTS="*"
|
||||
|
||||
export AGENTAPI_PID_FILE="${PID_FILE_PATH:-$module_path/agentapi.pid}"
|
||||
# Only set state env vars when persistence is enabled and the binary supports
|
||||
# it. State persistence requires agentapi >= v0.12.0.
|
||||
if [ "${ENABLE_STATE_PERSISTENCE}" = "true" ]; then
|
||||
actual_version=$(agentapi_version)
|
||||
if version_at_least 0.12.0 "$actual_version"; then
|
||||
export AGENTAPI_STATE_FILE="${STATE_FILE_PATH:-$module_path/agentapi-state.json}"
|
||||
export AGENTAPI_SAVE_STATE="true"
|
||||
export AGENTAPI_LOAD_STATE="true"
|
||||
else
|
||||
echo "Warning: State persistence requires agentapi >= v0.12.0 (current: ${actual_version:-unknown}), skipping."
|
||||
fi
|
||||
fi
|
||||
nohup "$module_path/scripts/agentapi-start.sh" true "${AGENTAPI_PORT}" &> "$module_path/agentapi-start.log" &
|
||||
"$module_path/scripts/agentapi-wait-for-start.sh" "${AGENTAPI_PORT}"
|
||||
@@ -46,7 +46,25 @@ export const setupContainer = async ({
|
||||
agent_id: "foo",
|
||||
...vars,
|
||||
});
|
||||
const coderScript = findResourceInstance(state, "coder_script");
|
||||
// Find the run_on_start script. With coder-utils the install script lives
|
||||
// inside a module and the shutdown script is a separate resource, so we
|
||||
// pick the first coder_script that has run_on_start = true.
|
||||
let coderScript: { script: string; [k: string]: unknown } | undefined;
|
||||
for (const resource of state.resources) {
|
||||
if (resource.type !== "coder_script") continue;
|
||||
for (const instance of resource.instances) {
|
||||
const attrs = instance.attributes as Record<string, unknown>;
|
||||
if (attrs.run_on_start === true) {
|
||||
coderScript = attrs as { script: string; [k: string]: unknown };
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (coderScript) break;
|
||||
}
|
||||
if (!coderScript) {
|
||||
// Fallback to original behavior for backwards compatibility.
|
||||
coderScript = findResourceInstance(state, "coder_script");
|
||||
}
|
||||
const coderEnvVars = extractCoderEnvVars(state);
|
||||
const id = await runContainer(image ?? "codercom/enterprise-node:latest");
|
||||
return {
|
||||
|
||||
@@ -31,16 +31,6 @@ for (const v of [
|
||||
);
|
||||
}
|
||||
}
|
||||
// Log boundary env vars.
|
||||
for (const v of ["AGENTAPI_BOUNDARY_PREFIX"]) {
|
||||
if (process.env[v]) {
|
||||
fs.appendFileSync(
|
||||
"/home/coder/agentapi-mock.log",
|
||||
`\n${v}: ${process.env[v]}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Write PID file for shutdown script.
|
||||
if (process.env.AGENTAPI_PID_FILE) {
|
||||
const path = require("path");
|
||||
|
||||
+5
-15
@@ -5,8 +5,8 @@ set -o pipefail
|
||||
use_prompt=${1:-false}
|
||||
port=${2:-3284}
|
||||
|
||||
module_path="$HOME/.agentapi-module"
|
||||
log_file_path="$module_path/agentapi.log"
|
||||
module_directory="$HOME/.agentapi-module"
|
||||
log_file_path="$module_directory/agentapi.log"
|
||||
|
||||
echo "using prompt: $use_prompt" >> /home/coder/test-agentapi-start.log
|
||||
echo "using port: $port" >> /home/coder/test-agentapi-start.log
|
||||
@@ -17,16 +17,6 @@ if [ -n "$AGENTAPI_CHAT_BASE_PATH" ]; then
|
||||
export AGENTAPI_CHAT_BASE_PATH
|
||||
fi
|
||||
|
||||
# Use boundary wrapper if configured by agentapi module.
|
||||
# AGENTAPI_BOUNDARY_PREFIX is set by the agentapi module's main.sh
|
||||
# and points to a wrapper script that runs the command through coder boundary.
|
||||
if [ -n "${AGENTAPI_BOUNDARY_PREFIX:-}" ]; then
|
||||
echo "Starting with boundary: ${AGENTAPI_BOUNDARY_PREFIX}" >> /home/coder/test-agentapi-start.log
|
||||
agentapi server --port "$port" --term-width 67 --term-height 1190 -- \
|
||||
"${AGENTAPI_BOUNDARY_PREFIX}" bash -c aiagent \
|
||||
> "$log_file_path" 2>&1
|
||||
else
|
||||
agentapi server --port "$port" --term-width 67 --term-height 1190 -- \
|
||||
bash -c aiagent \
|
||||
> "$log_file_path" 2>&1
|
||||
fi
|
||||
agentapi server --port "$port" --term-width 67 --term-height 1190 -- \
|
||||
bash -c aiagent \
|
||||
> "$log_file_path" 2>&1
|
||||
|
||||
@@ -13,7 +13,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.8.1"
|
||||
version = "4.9.2"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
claude_api_key = "xxxx-xxxxx-xxxx"
|
||||
@@ -60,7 +60,7 @@ By default, when `enable_boundary = true`, the module uses `coder boundary` subc
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.8.1"
|
||||
version = "4.9.2"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
enable_boundary = true
|
||||
@@ -81,7 +81,7 @@ For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.8.1"
|
||||
version = "4.9.2"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
enable_aibridge = true
|
||||
@@ -110,7 +110,7 @@ data "coder_task" "me" {}
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.8.1"
|
||||
version = "4.9.2"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
ai_prompt = data.coder_task.me.prompt
|
||||
@@ -133,7 +133,7 @@ This example shows additional configuration options for version pinning, custom
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.8.1"
|
||||
version = "4.9.2"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
|
||||
@@ -189,7 +189,7 @@ Run and configure Claude Code as a standalone CLI in your workspace.
|
||||
```tf
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.8.1"
|
||||
version = "4.9.2"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
install_claude_code = true
|
||||
@@ -211,7 +211,7 @@ variable "claude_code_oauth_token" {
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.8.1"
|
||||
version = "4.9.2"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
claude_code_oauth_token = var.claude_code_oauth_token
|
||||
@@ -284,7 +284,7 @@ resource "coder_env" "bedrock_api_key" {
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.8.1"
|
||||
version = "4.9.2"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||
@@ -341,7 +341,7 @@ resource "coder_env" "google_application_credentials" {
|
||||
|
||||
module "claude-code" {
|
||||
source = "registry.coder.com/coder/claude-code/coder"
|
||||
version = "4.8.1"
|
||||
version = "4.9.2"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/project"
|
||||
model = "claude-sonnet-4@20250514"
|
||||
|
||||
@@ -182,6 +182,24 @@ describe("claude-code", async () => {
|
||||
expect(startLog.stdout).toContain(`--permission-mode ${mode}`);
|
||||
});
|
||||
|
||||
test("claude-auto-permission-mode", async () => {
|
||||
const mode = "auto";
|
||||
const { id } = await setup({
|
||||
moduleVariables: {
|
||||
permission_mode: mode,
|
||||
ai_prompt: "test prompt",
|
||||
},
|
||||
});
|
||||
await execModuleScript(id);
|
||||
|
||||
const startLog = await execContainer(id, [
|
||||
"bash",
|
||||
"-c",
|
||||
"cat /home/coder/.claude-module/agentapi-start.log",
|
||||
]);
|
||||
expect(startLog.stdout).toContain(`--permission-mode ${mode}`);
|
||||
});
|
||||
|
||||
test("claude-model", async () => {
|
||||
const model = "opus";
|
||||
const { coderEnvVars } = await setup({
|
||||
|
||||
@@ -47,6 +47,12 @@ variable "report_tasks" {
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "web_app" {
|
||||
type = bool
|
||||
description = "Whether to create the web app for Claude Code. When false, AgentAPI still runs but no web UI app icon is shown in the Coder dashboard. This is automatically enabled when using Coder Tasks, regardless of this setting."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "cli_app" {
|
||||
type = bool
|
||||
description = "Whether to create a CLI app for Claude Code"
|
||||
@@ -155,8 +161,8 @@ variable "permission_mode" {
|
||||
description = "Permission mode for the cli, check https://docs.anthropic.com/en/docs/claude-code/iam#permission-modes"
|
||||
default = ""
|
||||
validation {
|
||||
condition = contains(["", "default", "acceptEdits", "plan", "bypassPermissions"], var.permission_mode)
|
||||
error_message = "interaction_mode must be one of: default, acceptEdits, plan, bypassPermissions."
|
||||
condition = contains(["", "default", "acceptEdits", "plan", "auto", "bypassPermissions"], var.permission_mode)
|
||||
error_message = "interaction_mode must be one of: default, acceptEdits, plan, auto, bypassPermissions."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,7 +293,7 @@ resource "coder_env" "claude_code_oauth_token" {
|
||||
}
|
||||
|
||||
resource "coder_env" "claude_api_key" {
|
||||
count = local.claude_api_key != "" ? 1 : 0
|
||||
count = (var.enable_aibridge || (var.claude_api_key != "")) ? 1 : 0
|
||||
|
||||
agent_id = var.agent_id
|
||||
name = "CLAUDE_API_KEY"
|
||||
@@ -362,9 +368,10 @@ locals {
|
||||
|
||||
module "agentapi" {
|
||||
source = "registry.coder.com/coder/agentapi/coder"
|
||||
version = "2.2.0"
|
||||
version = "2.4.0"
|
||||
|
||||
agent_id = var.agent_id
|
||||
web_app = var.web_app
|
||||
web_app_slug = local.app_slug
|
||||
web_app_order = var.order
|
||||
web_app_group = var.group
|
||||
@@ -423,6 +430,7 @@ module "agentapi" {
|
||||
ARG_MCP='${var.mcp != null ? base64encode(replace(var.mcp, "'", "'\\''")) : ""}' \
|
||||
ARG_MCP_CONFIG_REMOTE_PATH='${base64encode(jsonencode(var.mcp_config_remote_path))}' \
|
||||
ARG_ENABLE_AIBRIDGE='${var.enable_aibridge}' \
|
||||
ARG_PERMISSION_MODE='${var.permission_mode}' \
|
||||
/tmp/install.sh
|
||||
EOT
|
||||
}
|
||||
|
||||
@@ -183,11 +183,26 @@ run "test_claude_code_permission_mode_validation" {
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = contains(["", "default", "acceptEdits", "plan", "bypassPermissions"], var.permission_mode)
|
||||
condition = contains(["", "default", "acceptEdits", "plan", "auto", "bypassPermissions"], var.permission_mode)
|
||||
error_message = "Permission mode should be one of the valid options"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_claude_code_auto_permission_mode" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-auto"
|
||||
workdir = "/home/coder/test"
|
||||
permission_mode = "auto"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.permission_mode == "auto"
|
||||
error_message = "Permission mode should be set to auto"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_claude_code_with_boundary" {
|
||||
command = plan
|
||||
|
||||
@@ -416,7 +431,6 @@ run "test_disable_state_persistence" {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
run "test_no_api_key_no_env" {
|
||||
command = plan
|
||||
|
||||
@@ -431,3 +445,18 @@ run "test_no_api_key_no_env" {
|
||||
error_message = "CLAUDE_API_KEY should not be created when no API key is provided and aibridge is disabled"
|
||||
}
|
||||
}
|
||||
|
||||
run "test_api_key_count_with_aibridge_no_override" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-count"
|
||||
workdir = "/home/coder/test"
|
||||
enable_aibridge = true
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(coder_env.claude_api_key) == 1
|
||||
error_message = "CLAUDE_API_KEY env should be created when aibridge is enabled, regardless of session_token value"
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ ARG_MCP_CONFIG_REMOTE_PATH=$(echo -n "${ARG_MCP_CONFIG_REMOTE_PATH:-}" | base64
|
||||
ARG_ALLOWED_TOOLS=${ARG_ALLOWED_TOOLS:-}
|
||||
ARG_DISALLOWED_TOOLS=${ARG_DISALLOWED_TOOLS:-}
|
||||
ARG_ENABLE_AIBRIDGE=${ARG_ENABLE_AIBRIDGE:-false}
|
||||
ARG_PERMISSION_MODE=${ARG_PERMISSION_MODE:-}
|
||||
|
||||
export PATH="$ARG_CLAUDE_BINARY_PATH:$PATH"
|
||||
|
||||
@@ -195,6 +196,7 @@ function configure_standalone_mode() {
|
||||
|
||||
jq --arg workdir "$ARG_WORKDIR" --arg apikey "${CLAUDE_API_KEY:-}" \
|
||||
'.autoUpdaterStatus = "disabled" |
|
||||
.autoModeAccepted = true |
|
||||
.bypassPermissionsModeAccepted = true |
|
||||
.hasAcknowledgedCostThreshold = true |
|
||||
.hasCompletedOnboarding = true |
|
||||
@@ -207,6 +209,7 @@ function configure_standalone_mode() {
|
||||
cat > "$claude_config" << EOF
|
||||
{
|
||||
"autoUpdaterStatus": "disabled",
|
||||
"autoModeAccepted": true,
|
||||
"bypassPermissionsModeAccepted": true,
|
||||
"hasAcknowledgedCostThreshold": true,
|
||||
"hasCompletedOnboarding": true,
|
||||
@@ -235,6 +238,28 @@ function report_tasks() {
|
||||
fi
|
||||
}
|
||||
|
||||
function accept_auto_mode() {
|
||||
# Pre-accept the auto mode TOS prompt so it doesn't appear interactively.
|
||||
# Claude Code shows a confirmation dialog for auto mode that blocks
|
||||
# non-interactive/headless usage.
|
||||
# Note: bypassPermissions acceptance is already handled by
|
||||
# coder exp mcp configure (task mode) and configure_standalone_mode.
|
||||
local claude_config="$HOME/.claude.json"
|
||||
|
||||
if [ -f "$claude_config" ]; then
|
||||
jq '.autoModeAccepted = true' \
|
||||
"$claude_config" > "${claude_config}.tmp" && mv "${claude_config}.tmp" "$claude_config"
|
||||
else
|
||||
echo '{"autoModeAccepted": true}' > "$claude_config"
|
||||
fi
|
||||
|
||||
echo "Pre-accepted auto mode prompt"
|
||||
}
|
||||
|
||||
install_claude_code_cli
|
||||
setup_claude_configurations
|
||||
report_tasks
|
||||
|
||||
if [ "$ARG_PERMISSION_MODE" = "auto" ]; then
|
||||
accept_auto_mode
|
||||
fi
|
||||
|
||||
@@ -14,7 +14,7 @@ Automatically install [code-server](https://github.com/coder/code-server) in a w
|
||||
module "code-server" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/code-server/coder"
|
||||
version = "1.4.3"
|
||||
version = "1.4.4"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
@@ -29,7 +29,7 @@ module "code-server" {
|
||||
module "code-server" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/code-server/coder"
|
||||
version = "1.4.3"
|
||||
version = "1.4.4"
|
||||
agent_id = coder_agent.example.id
|
||||
install_version = "4.106.3"
|
||||
}
|
||||
@@ -43,7 +43,7 @@ Install the Dracula theme from [OpenVSX](https://open-vsx.org/):
|
||||
module "code-server" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/code-server/coder"
|
||||
version = "1.4.3"
|
||||
version = "1.4.4"
|
||||
agent_id = coder_agent.example.id
|
||||
extensions = [
|
||||
"dracula-theme.theme-dracula"
|
||||
@@ -61,7 +61,7 @@ Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarte
|
||||
module "code-server" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/code-server/coder"
|
||||
version = "1.4.3"
|
||||
version = "1.4.4"
|
||||
agent_id = coder_agent.example.id
|
||||
extensions = ["dracula-theme.theme-dracula"]
|
||||
settings = {
|
||||
@@ -72,13 +72,13 @@ module "code-server" {
|
||||
|
||||
### Install multiple extensions
|
||||
|
||||
Just run code-server in the background, don't fetch it from GitHub:
|
||||
Install multiple extensions from [OpenVSX](https://open-vsx.org/) by adding them to the `extensions` list:
|
||||
|
||||
```tf
|
||||
module "code-server" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/code-server/coder"
|
||||
version = "1.4.3"
|
||||
version = "1.4.4"
|
||||
agent_id = coder_agent.example.id
|
||||
extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"]
|
||||
}
|
||||
@@ -92,7 +92,7 @@ You can pass additional command-line arguments to code-server using the `additio
|
||||
module "code-server" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/code-server/coder"
|
||||
version = "1.4.3"
|
||||
version = "1.4.4"
|
||||
agent_id = coder_agent.example.id
|
||||
additional_args = "--disable-workspace-trust"
|
||||
}
|
||||
@@ -108,7 +108,7 @@ Run an existing copy of code-server if found, otherwise download from GitHub:
|
||||
module "code-server" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/code-server/coder"
|
||||
version = "1.4.3"
|
||||
version = "1.4.4"
|
||||
agent_id = coder_agent.example.id
|
||||
use_cached = true
|
||||
extensions = ["dracula-theme.theme-dracula", "ms-azuretools.vscode-docker"]
|
||||
@@ -121,7 +121,7 @@ Just run code-server in the background, don't fetch it from GitHub:
|
||||
module "code-server" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/code-server/coder"
|
||||
version = "1.4.3"
|
||||
version = "1.4.4"
|
||||
agent_id = coder_agent.example.id
|
||||
offline = true
|
||||
}
|
||||
|
||||
+6
-6
@@ -1,26 +1,26 @@
|
||||
---
|
||||
display_name: Agent Helper
|
||||
display_name: Coder Utils
|
||||
description: Building block for modules that need orchestrated script execution
|
||||
icon: ../../../../.icons/coder.svg
|
||||
verified: false
|
||||
tags: [internal, library]
|
||||
---
|
||||
|
||||
# Agent Helper
|
||||
# Coder Utils
|
||||
|
||||
> [!CAUTION]
|
||||
> We do not recommend using this module directly. It is intended primarily for internal use by Coder to create modules with orchestrated script execution.
|
||||
|
||||
The Agent Helper module is a building block for modules that need to run multiple scripts in a specific order. It uses `coder exp sync` for dependency management and is designed for orchestrating pre-install, install, post-install, and start scripts.
|
||||
The Coder Utils module is a building block for modules that need to run multiple scripts in a specific order. It uses `coder exp sync` for dependency management and is designed for orchestrating pre-install, install, post-install, and start scripts.
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> - The `agent_name` should be the same as that of the agentapi module's `agent_name` if used together.
|
||||
|
||||
```tf
|
||||
module "agent_helper" {
|
||||
source = "registry.coder.com/coder/agent-helper/coder"
|
||||
version = "1.0.0"
|
||||
module "coder_utils" {
|
||||
source = "registry.coder.com/coder/coder-utils/coder"
|
||||
version = "1.0.1"
|
||||
|
||||
agent_id = coder_agent.main.id
|
||||
agent_name = "myagent"
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { describe } from "bun:test";
|
||||
import { runTerraformInit, testRequiredVariables } from "~test";
|
||||
|
||||
describe("agent-helper", async () => {
|
||||
describe("coder-utils", async () => {
|
||||
await runTerraformInit(import.meta.dir);
|
||||
|
||||
testRequiredVariables(import.meta.dir, {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# Test for agent-helper module
|
||||
# Test for coder-utils module
|
||||
|
||||
# Test with all scripts provided
|
||||
run "test_with_all_scripts" {
|
||||
@@ -18,7 +18,7 @@ Under the hood, this module uses the [coder dotfiles](https://coder.com/docs/v2/
|
||||
module "dotfiles" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.4.0"
|
||||
version = "1.4.1"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
@@ -31,7 +31,7 @@ module "dotfiles" {
|
||||
module "dotfiles" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.4.0"
|
||||
version = "1.4.1"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
```
|
||||
@@ -42,7 +42,7 @@ module "dotfiles" {
|
||||
module "dotfiles" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.4.0"
|
||||
version = "1.4.1"
|
||||
agent_id = coder_agent.example.id
|
||||
user = "root"
|
||||
}
|
||||
@@ -54,14 +54,14 @@ module "dotfiles" {
|
||||
module "dotfiles" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.4.0"
|
||||
version = "1.4.1"
|
||||
agent_id = coder_agent.example.id
|
||||
}
|
||||
|
||||
module "dotfiles-root" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.4.0"
|
||||
version = "1.4.1"
|
||||
agent_id = coder_agent.example.id
|
||||
user = "root"
|
||||
dotfiles_uri = module.dotfiles.dotfiles_uri
|
||||
@@ -90,7 +90,7 @@ You can set a default dotfiles repository for all users by setting the `default_
|
||||
module "dotfiles" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/dotfiles/coder"
|
||||
version = "1.4.0"
|
||||
version = "1.4.1"
|
||||
agent_id = coder_agent.example.id
|
||||
default_dotfiles_uri = "https://github.com/coder/dotfiles"
|
||||
}
|
||||
|
||||
@@ -56,6 +56,21 @@ describe("dotfiles", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("command uses bash for fish shell compatibility", async () => {
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
agent_id: "foo",
|
||||
manual_update: "true",
|
||||
dotfiles_uri: "https://github.com/test/dotfiles",
|
||||
});
|
||||
|
||||
const app = state.resources.find(
|
||||
(r) => r.type === "coder_app" && r.name === "dotfiles",
|
||||
);
|
||||
|
||||
expect(app).toBeDefined();
|
||||
expect(app?.instances[0]?.attributes?.command).toContain("/bin/bash -c");
|
||||
});
|
||||
|
||||
it("set custom order for coder_parameter", async () => {
|
||||
const order = 99;
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
|
||||
@@ -164,12 +164,12 @@ resource "coder_app" "dotfiles" {
|
||||
icon = "/icon/dotfiles.svg"
|
||||
order = var.order
|
||||
group = var.group
|
||||
command = templatefile("${path.module}/run.sh", {
|
||||
command = "/bin/bash -c \"$(echo ${base64encode(templatefile("${path.module}/run.sh", {
|
||||
DOTFILES_URI : local.dotfiles_uri,
|
||||
DOTFILES_USER : local.user,
|
||||
DOTFILES_BRANCH : local.dotfiles_branch,
|
||||
POST_CLONE_SCRIPT : local.encoded_post_clone_script
|
||||
})
|
||||
}))} | base64 -d)\""
|
||||
}
|
||||
|
||||
output "dotfiles_uri" {
|
||||
|
||||
@@ -14,7 +14,7 @@ This module adds JetBrains IDE buttons to launch IDEs directly from the dashboar
|
||||
module "jetbrains" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains/coder"
|
||||
version = "1.3.0"
|
||||
version = "1.4.0"
|
||||
agent_id = coder_agent.main.id
|
||||
folder = "/home/coder/project"
|
||||
}
|
||||
@@ -39,7 +39,7 @@ When `default` contains IDE codes, those IDEs are created directly without user
|
||||
module "jetbrains" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains/coder"
|
||||
version = "1.3.0"
|
||||
version = "1.4.0"
|
||||
agent_id = coder_agent.main.id
|
||||
folder = "/home/coder/project"
|
||||
default = ["PY", "IU"] # Pre-configure PyCharm and IntelliJ IDEA
|
||||
@@ -52,7 +52,7 @@ module "jetbrains" {
|
||||
module "jetbrains" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains/coder"
|
||||
version = "1.3.0"
|
||||
version = "1.4.0"
|
||||
agent_id = coder_agent.main.id
|
||||
folder = "/home/coder/project"
|
||||
# Show parameter with limited options
|
||||
@@ -66,7 +66,7 @@ module "jetbrains" {
|
||||
module "jetbrains" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains/coder"
|
||||
version = "1.3.0"
|
||||
version = "1.4.0"
|
||||
agent_id = coder_agent.main.id
|
||||
folder = "/home/coder/project"
|
||||
default = ["IU", "PY"]
|
||||
@@ -75,30 +75,37 @@ module "jetbrains" {
|
||||
}
|
||||
```
|
||||
|
||||
### Custom IDE Configuration
|
||||
### Pinned Versions (Air-Gapped / Cached)
|
||||
|
||||
When `ide_config` is set, the module makes zero HTTP calls and uses the
|
||||
provided build numbers directly. This is ideal for air-gapped environments
|
||||
or when caching IDE installations.
|
||||
|
||||
> [!TIP]
|
||||
> To find the latest build number for an IDE, query the JetBrains releases API:
|
||||
>
|
||||
> ```sh
|
||||
> curl -s "https://data.services.jetbrains.com/products/releases?code=GO&type=release&latest=true" | jq 'to_entries[0].value[0] | {build, version}'
|
||||
> ```
|
||||
>
|
||||
> Replace `GO` with the product code for the IDE you want (e.g. `IU`, `PY`, `CL`).
|
||||
|
||||
```tf
|
||||
module "jetbrains" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains/coder"
|
||||
version = "1.3.0"
|
||||
version = "1.4.0"
|
||||
agent_id = coder_agent.main.id
|
||||
folder = "/workspace/project"
|
||||
folder = "/home/coder/project"
|
||||
|
||||
# Custom IDE metadata (display names and icons)
|
||||
# Only build is required. Name and icon fall back to built-in defaults.
|
||||
ide_config = {
|
||||
"IU" = {
|
||||
name = "IntelliJ IDEA"
|
||||
icon = "/custom/icons/intellij.svg"
|
||||
build = "251.26927.53"
|
||||
}
|
||||
|
||||
"PY" = {
|
||||
name = "PyCharm"
|
||||
icon = "/custom/icons/pycharm.svg"
|
||||
build = "251.23774.211"
|
||||
}
|
||||
"GO" = { build = "261.22158.291" }
|
||||
"PY" = { build = "261.22158.340" }
|
||||
# Add entries for other IDEs as needed.
|
||||
}
|
||||
|
||||
options = ["GO", "PY"] # Must match the keys in ide_config.
|
||||
}
|
||||
```
|
||||
|
||||
@@ -108,7 +115,7 @@ module "jetbrains" {
|
||||
module "jetbrains_pycharm" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains/coder"
|
||||
version = "1.3.0"
|
||||
version = "1.4.0"
|
||||
agent_id = coder_agent.main.id
|
||||
folder = "/workspace/project"
|
||||
|
||||
@@ -128,7 +135,7 @@ Add helpful tooltip text that appears when users hover over the IDE app buttons:
|
||||
module "jetbrains" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/jetbrains/coder"
|
||||
version = "1.3.0"
|
||||
version = "1.4.0"
|
||||
agent_id = coder_agent.main.id
|
||||
folder = "/home/coder/project"
|
||||
default = ["IU", "PY"]
|
||||
@@ -165,9 +172,9 @@ resource "coder_metadata" "container_info" {
|
||||
|
||||
### Version Resolution
|
||||
|
||||
- Build numbers are fetched from the JetBrains API for the latest compatible versions when internet access is available
|
||||
- If the API is unreachable (air-gapped environments), the module automatically falls back to build numbers from `ide_config`
|
||||
- `major_version` and `channel` control which API endpoint is queried (when API access is available)
|
||||
- **`ide_config` not set (default)**: Build numbers are fetched from the JetBrains releases API. If the API is unreachable, Terraform will return an error rather than silently using stale versions.
|
||||
- **`ide_config` set**: The module skips all HTTP calls and uses the provided build numbers directly. No network access required. Ideal for air-gapped deployments or when caching IDE installations.
|
||||
- `major_version` and `channel` control which API endpoint is queried (only when `ide_config` is not set).
|
||||
|
||||
## Supported IDEs
|
||||
|
||||
|
||||
@@ -1,53 +1,3 @@
|
||||
variables {
|
||||
# Default IDE config, mirrored from main.tf for test assertions.
|
||||
# If main.tf defaults change, update this map to match.
|
||||
expected_ide_config = {
|
||||
"CL" = { name = "CLion", icon = "/icon/clion.svg", build = "253.29346.141" },
|
||||
"GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "253.28294.337" },
|
||||
"IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg", build = "253.29346.138" },
|
||||
"PS" = { name = "PhpStorm", icon = "/icon/phpstorm.svg", build = "253.29346.151" },
|
||||
"PY" = { name = "PyCharm", icon = "/icon/pycharm.svg", build = "253.29346.142" },
|
||||
"RD" = { name = "Rider", icon = "/icon/rider.svg", build = "253.29346.144" },
|
||||
"RM" = { name = "RubyMine", icon = "/icon/rubymine.svg", build = "253.29346.140" },
|
||||
"RR" = { name = "RustRover", icon = "/icon/rustrover.svg", build = "253.29346.139" },
|
||||
"WS" = { name = "WebStorm", icon = "/icon/webstorm.svg", build = "253.29346.143" }
|
||||
}
|
||||
}
|
||||
|
||||
run "validate_test_config_matches_defaults" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
# Provide minimal vars to allow plan to read module variables
|
||||
agent_id = "foo"
|
||||
folder = "/home/coder"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(var.ide_config) == length(var.expected_ide_config)
|
||||
error_message = "Test configuration mismatch: 'var.ide_config' in main.tf has ${length(var.ide_config)} items, but 'var.expected_ide_config' in the test file has ${length(var.expected_ide_config)} items. Please update the test file's global variables block."
|
||||
}
|
||||
|
||||
assert {
|
||||
# Check that all keys in the test local are present in the module's default
|
||||
condition = alltrue([
|
||||
for key in keys(var.expected_ide_config) :
|
||||
can(var.ide_config[key])
|
||||
])
|
||||
error_message = "Test configuration mismatch: Keys in 'var.expected_ide_config' are out of sync with 'var.ide_config' defaults. Please update the test file's global variables block."
|
||||
}
|
||||
|
||||
assert {
|
||||
# Check if all build numbers in the test local match the module's defaults
|
||||
# This relies on the previous two assertions passing (same length, same keys)
|
||||
condition = alltrue([
|
||||
for key, config in var.expected_ide_config :
|
||||
var.ide_config[key].build == config.build
|
||||
])
|
||||
error_message = "Test configuration mismatch: One or more build numbers in 'var.expected_ide_config' do not match the defaults in 'var.ide_config'. Please update the test file's global variables block."
|
||||
}
|
||||
}
|
||||
|
||||
run "requires_agent_and_folder" {
|
||||
command = plan
|
||||
|
||||
@@ -259,15 +209,17 @@ run "output_empty_when_default_empty" {
|
||||
}
|
||||
}
|
||||
|
||||
run "output_single_ide_uses_fallback_build" {
|
||||
run "uses_ide_config_when_set" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
folder = "/home/coder"
|
||||
default = ["GO"]
|
||||
# Force HTTP data source to fail to test fallback logic
|
||||
releases_base_link = "https://coder.com"
|
||||
options = ["GO"]
|
||||
ide_config = {
|
||||
"GO" = { name = "GoLand Custom", icon = "/icon/goland.svg", build = "999.99999.999" }
|
||||
}
|
||||
}
|
||||
|
||||
assert {
|
||||
@@ -281,30 +233,38 @@ run "output_single_ide_uses_fallback_build" {
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.ide_metadata["GO"].name == var.expected_ide_config["GO"].name
|
||||
error_message = "Expected ide_metadata['GO'].name to be '${var.expected_ide_config["GO"].name}'"
|
||||
condition = output.ide_metadata["GO"].name == "GoLand Custom"
|
||||
error_message = "Expected ide_metadata['GO'].name to be 'GoLand Custom'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.ide_metadata["GO"].build == var.expected_ide_config["GO"].build
|
||||
error_message = "Expected ide_metadata['GO'].build to use the fallback '${var.expected_ide_config["GO"].build}'"
|
||||
condition = output.ide_metadata["GO"].build == "999.99999.999"
|
||||
error_message = "Expected ide_metadata['GO'].build to use the pinned build '999.99999.999'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.ide_metadata["GO"].icon == var.expected_ide_config["GO"].icon
|
||||
error_message = "Expected ide_metadata['GO'].icon to be '${var.expected_ide_config["GO"].icon}'"
|
||||
condition = output.ide_metadata["GO"].icon == "/icon/goland.svg"
|
||||
error_message = "Expected ide_metadata['GO'].icon to be '/icon/goland.svg'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.ide_metadata["GO"].json_data == null
|
||||
error_message = "Expected ide_metadata['GO'].json_data to be null when using ide_config"
|
||||
}
|
||||
}
|
||||
|
||||
run "output_multiple_ides" {
|
||||
run "uses_ide_config_for_multiple_ides" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
folder = "/home/coder"
|
||||
default = ["IU", "PY"]
|
||||
# Force HTTP data source to fail to test fallback logic
|
||||
releases_base_link = "https://coder.com"
|
||||
options = ["IU", "PY"]
|
||||
ide_config = {
|
||||
"IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg", build = "111.11111.111" }
|
||||
"PY" = { name = "PyCharm", icon = "/icon/pycharm.svg", build = "222.22222.222" }
|
||||
}
|
||||
}
|
||||
|
||||
assert {
|
||||
@@ -318,15 +278,50 @@ run "output_multiple_ides" {
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.ide_metadata["PY"].name == var.expected_ide_config["PY"].name
|
||||
error_message = "Expected ide_metadata['PY'].name to be '${var.expected_ide_config["PY"].name}'"
|
||||
condition = output.ide_metadata["PY"].name == "PyCharm"
|
||||
error_message = "Expected ide_metadata['PY'].name to be 'PyCharm'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.ide_metadata["PY"].build == var.expected_ide_config["PY"].build
|
||||
error_message = "Expected ide_metadata['PY'].build to be the fallback '${var.expected_ide_config["PY"].build}'"
|
||||
condition = output.ide_metadata["PY"].build == "222.22222.222"
|
||||
error_message = "Expected ide_metadata['PY'].build to be the pinned build '222.22222.222'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.ide_metadata["IU"].build == "111.11111.111"
|
||||
error_message = "Expected ide_metadata['IU'].build to be the pinned build '111.11111.111'"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.ide_metadata["IU"].json_data == null
|
||||
error_message = "Expected ide_metadata['IU'].json_data to be null when using ide_config"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.ide_metadata["PY"].json_data == null
|
||||
error_message = "Expected ide_metadata['PY'].json_data to be null when using ide_config"
|
||||
}
|
||||
}
|
||||
|
||||
run "ide_config_build_in_url" {
|
||||
command = apply
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-123"
|
||||
folder = "/home/coder/project"
|
||||
default = ["GO"]
|
||||
options = ["GO"]
|
||||
ide_config = {
|
||||
"GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "999.99999.999" }
|
||||
}
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = anytrue([for app in values(resource.coder_app.jetbrains) : length(regexall("ide_build_number=999.99999.999", app.url)) > 0])
|
||||
error_message = "URL must include the pinned build number from ide_config"
|
||||
}
|
||||
}
|
||||
|
||||
run "validate_output_schema" {
|
||||
command = plan
|
||||
|
||||
@@ -334,6 +329,10 @@ run "validate_output_schema" {
|
||||
agent_id = "foo"
|
||||
folder = "/home/coder"
|
||||
default = ["GO"]
|
||||
options = ["GO"]
|
||||
ide_config = {
|
||||
"GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "253.28294.337" }
|
||||
}
|
||||
}
|
||||
|
||||
assert {
|
||||
@@ -351,3 +350,107 @@ run "validate_output_schema" {
|
||||
error_message = "The ide_metadata output schema has changed. Please update the 'main.tf' and this test."
|
||||
}
|
||||
}
|
||||
|
||||
run "rejects_major_version_with_ide_config" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
folder = "/home/coder"
|
||||
default = ["GO"]
|
||||
options = ["GO"]
|
||||
major_version = "2025.3"
|
||||
ide_config = {
|
||||
"GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "253.31033.129" }
|
||||
}
|
||||
}
|
||||
|
||||
expect_failures = [
|
||||
var.ide_config,
|
||||
]
|
||||
}
|
||||
|
||||
run "rejects_default_not_in_ide_config" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
folder = "/home/coder"
|
||||
default = ["GO", "IU"]
|
||||
options = ["GO", "IU"]
|
||||
ide_config = {
|
||||
"GO" = { build = "253.31033.129" }
|
||||
}
|
||||
}
|
||||
|
||||
expect_failures = [
|
||||
var.ide_config,
|
||||
]
|
||||
}
|
||||
|
||||
run "ide_config_with_build_only" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
folder = "/home/coder"
|
||||
default = ["GO"]
|
||||
options = ["GO"]
|
||||
ide_config = {
|
||||
"GO" = { build = "999.99999.999" }
|
||||
}
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.ide_metadata["GO"].name == "GoLand"
|
||||
error_message = "Expected name to fall back to ide_metadata when not set in ide_config"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.ide_metadata["GO"].icon == "/icon/goland.svg"
|
||||
error_message = "Expected icon to fall back to ide_metadata when not set in ide_config"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.ide_metadata["GO"].build == "999.99999.999"
|
||||
error_message = "Expected build to use ide_config value"
|
||||
}
|
||||
}
|
||||
|
||||
run "rejects_releases_base_link_with_ide_config" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
folder = "/home/coder"
|
||||
default = ["GO"]
|
||||
options = ["GO"]
|
||||
releases_base_link = "https://internal.mirror.example.com"
|
||||
ide_config = {
|
||||
"GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "253.31033.129" }
|
||||
}
|
||||
}
|
||||
|
||||
expect_failures = [
|
||||
var.ide_config,
|
||||
]
|
||||
}
|
||||
|
||||
run "rejects_channel_with_ide_config" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "foo"
|
||||
folder = "/home/coder"
|
||||
default = ["GO"]
|
||||
options = ["GO"]
|
||||
channel = "eap"
|
||||
ide_config = {
|
||||
"GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "253.31033.129" }
|
||||
}
|
||||
}
|
||||
|
||||
expect_failures = [
|
||||
var.ide_config,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -125,95 +125,128 @@ variable "download_base_link" {
|
||||
}
|
||||
|
||||
data "http" "jetbrains_ide_versions" {
|
||||
for_each = length(var.default) == 0 ? var.options : var.default
|
||||
for_each = var.ide_config == null ? local.selected_ides : toset([])
|
||||
url = "${var.releases_base_link}/products/releases?code=${each.key}&type=${var.channel}${var.major_version == "latest" ? "&latest=true" : ""}"
|
||||
}
|
||||
|
||||
variable "ide_config" {
|
||||
description = <<-EOT
|
||||
A map of JetBrains IDE configurations.
|
||||
The key is the product code and the value is an object with the following properties:
|
||||
- name: The name of the IDE.
|
||||
- icon: The icon of the IDE.
|
||||
- build: The build number of the IDE.
|
||||
Optional map of JetBrains IDE configurations keyed by product code.
|
||||
When null (default), the module fetches the latest build numbers from
|
||||
the JetBrains API at plan time. When set, all HTTP calls are skipped
|
||||
and the provided build numbers are used directly — useful for
|
||||
air-gapped environments or pinning specific versions.
|
||||
|
||||
Each value must contain:
|
||||
- build: Full build number (e.g. "253.28294.337").
|
||||
|
||||
Optionally override the default display name or icon:
|
||||
- name: Display name of the IDE (e.g. "GoLand").
|
||||
- icon: Path or URL to the IDE icon (e.g. "/icon/goland.svg").
|
||||
|
||||
Example:
|
||||
{
|
||||
"CL" = { name = "CLion", icon = "/icon/clion.svg", build = "253.29346.141" },
|
||||
"GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "253.28294.337" },
|
||||
"IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg", build = "253.29346.138" },
|
||||
"GO" = { build = "261.22158.291" },
|
||||
"IU" = { build = "261.22158.277" },
|
||||
}
|
||||
EOT
|
||||
type = map(object({
|
||||
name = string
|
||||
icon = string
|
||||
build = string
|
||||
name = optional(string)
|
||||
icon = optional(string)
|
||||
}))
|
||||
default = {
|
||||
"CL" = { name = "CLion", icon = "/icon/clion.svg", build = "253.29346.141" },
|
||||
"GO" = { name = "GoLand", icon = "/icon/goland.svg", build = "253.28294.337" },
|
||||
"IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg", build = "253.29346.138" },
|
||||
"PS" = { name = "PhpStorm", icon = "/icon/phpstorm.svg", build = "253.29346.151" },
|
||||
"PY" = { name = "PyCharm", icon = "/icon/pycharm.svg", build = "253.29346.142" },
|
||||
"RD" = { name = "Rider", icon = "/icon/rider.svg", build = "253.29346.144" },
|
||||
"RM" = { name = "RubyMine", icon = "/icon/rubymine.svg", build = "253.29346.140" },
|
||||
"RR" = { name = "RustRover", icon = "/icon/rustrover.svg", build = "253.29346.139" },
|
||||
"WS" = { name = "WebStorm", icon = "/icon/webstorm.svg", build = "253.29346.143" }
|
||||
}
|
||||
default = null
|
||||
validation {
|
||||
condition = length(var.ide_config) > 0
|
||||
condition = var.ide_config == null || length(var.ide_config) > 0
|
||||
error_message = "The ide_config must not be empty."
|
||||
}
|
||||
# ide_config must be a superset of var.options
|
||||
# Requires Terraform 1.9+ for cross-variable validation references
|
||||
validation {
|
||||
condition = alltrue([
|
||||
condition = var.ide_config == null || alltrue([
|
||||
for code in var.options : contains(keys(var.ide_config), code)
|
||||
])
|
||||
error_message = "The ide_config must be a superset of var.options."
|
||||
error_message = "The ide_config must contain entries for all IDE codes in var.options. Either add the missing entries to ide_config or narrow var.options to match."
|
||||
}
|
||||
# ide_config must also cover all codes in var.default to avoid
|
||||
# key-not-found errors when building options_metadata.
|
||||
validation {
|
||||
condition = var.ide_config == null || alltrue([
|
||||
for code in var.default : contains(keys(var.ide_config), code)
|
||||
])
|
||||
error_message = "The ide_config must contain entries for all IDE codes in var.default."
|
||||
}
|
||||
# major_version, channel, and releases_base_link only affect the
|
||||
# HTTP call, which is skipped when ide_config is set. Reject
|
||||
# non-default values to avoid silently ignoring user intent.
|
||||
validation {
|
||||
condition = var.ide_config == null || (
|
||||
var.major_version == "latest" &&
|
||||
var.channel == "release" &&
|
||||
var.releases_base_link == "https://data.services.jetbrains.com"
|
||||
)
|
||||
error_message = "major_version, channel, and releases_base_link have no effect when ide_config is set. Remove them or unset ide_config."
|
||||
}
|
||||
}
|
||||
|
||||
locals {
|
||||
# Parse HTTP responses once with error handling for air-gapped environments
|
||||
# Static IDE metadata for name and icon lookups when ide_config is null.
|
||||
ide_metadata = {
|
||||
"CL" = { name = "CLion", icon = "/icon/clion.svg" }
|
||||
"GO" = { name = "GoLand", icon = "/icon/goland.svg" }
|
||||
"IU" = { name = "IntelliJ IDEA", icon = "/icon/intellij.svg" }
|
||||
"PS" = { name = "PhpStorm", icon = "/icon/phpstorm.svg" }
|
||||
"PY" = { name = "PyCharm", icon = "/icon/pycharm.svg" }
|
||||
"RD" = { name = "Rider", icon = "/icon/rider.svg" }
|
||||
"RM" = { name = "RubyMine", icon = "/icon/rubymine.svg" }
|
||||
"RR" = { name = "RustRover", icon = "/icon/rustrover.svg" }
|
||||
"WS" = { name = "WebStorm", icon = "/icon/webstorm.svg" }
|
||||
}
|
||||
|
||||
# Determine the user's actual IDE selection.
|
||||
# This is computed before the HTTP data source so that version lookups
|
||||
# are only performed for IDEs the user chose — not every option.
|
||||
selected_ides = length(var.default) == 0 ? toset(jsondecode(coalesce(data.coder_parameter.jetbrains_ides[0].value, "[]"))) : toset(var.default)
|
||||
|
||||
# Parse HTTP responses. Only populated when ide_config is null
|
||||
# and the module fetches versions from the JetBrains API.
|
||||
# No try() fallback — if the API is expected and fails, Terraform
|
||||
# should error rather than silently using stale build numbers.
|
||||
parsed_responses = {
|
||||
for code in length(var.default) == 0 ? var.options : var.default : code => try(
|
||||
jsondecode(data.http.jetbrains_ide_versions[code].response_body),
|
||||
{} # Return empty object if API call fails
|
||||
)
|
||||
for code, response in data.http.jetbrains_ide_versions :
|
||||
code => jsondecode(response.response_body)
|
||||
}
|
||||
|
||||
# Filter the parsed response for the requested major version if not "latest"
|
||||
filtered_releases = {
|
||||
for code in length(var.default) == 0 ? var.options : var.default : code => [
|
||||
for r in try(local.parsed_responses[code][keys(local.parsed_responses[code])[0]], []) :
|
||||
for code, parsed in local.parsed_responses : code => [
|
||||
for r in parsed[keys(parsed)[0]] :
|
||||
r if var.major_version == "latest" || r.majorVersion == var.major_version
|
||||
]
|
||||
}
|
||||
|
||||
# Select the latest release for the requested major version (first item in the filtered list)
|
||||
selected_releases = {
|
||||
for code in length(var.default) == 0 ? var.options : var.default : code =>
|
||||
length(local.filtered_releases[code]) > 0 ? local.filtered_releases[code][0] : null
|
||||
for code, releases in local.filtered_releases :
|
||||
code => length(releases) > 0 ? releases[0] : null
|
||||
}
|
||||
|
||||
# Dynamically generate IDE configurations based on options with fallback to ide_config
|
||||
# Dynamically generate IDE configurations based on selected IDEs
|
||||
options_metadata = {
|
||||
for code in length(var.default) == 0 ? var.options : var.default : code => {
|
||||
icon = var.ide_config[code].icon
|
||||
name = var.ide_config[code].name
|
||||
for code in local.selected_ides : code => {
|
||||
icon = var.ide_config != null ? coalesce(var.ide_config[code].icon, local.ide_metadata[code].icon) : local.ide_metadata[code].icon
|
||||
name = var.ide_config != null ? coalesce(var.ide_config[code].name, local.ide_metadata[code].name) : local.ide_metadata[code].name
|
||||
identifier = code
|
||||
key = code
|
||||
|
||||
# Use API build number if available, otherwise fall back to ide_config build number
|
||||
build = local.selected_releases[code] != null ? local.selected_releases[code].build : var.ide_config[code].build
|
||||
# When ide_config is set, use the pinned build number directly.
|
||||
# When fetching from API, use the API result (fails if unavailable).
|
||||
build = var.ide_config != null ? var.ide_config[code].build : local.selected_releases[code].build
|
||||
|
||||
# Store API data for potential future use
|
||||
json_data = local.selected_releases[code]
|
||||
# API response data, null when using ide_config.
|
||||
json_data = var.ide_config != null ? null : local.selected_releases[code]
|
||||
}
|
||||
}
|
||||
|
||||
# Convert the parameter value to a set for for_each
|
||||
selected_ides = length(var.default) == 0 ? toset(jsondecode(coalesce(data.coder_parameter.jetbrains_ides[0].value, "[]"))) : toset(var.default)
|
||||
}
|
||||
|
||||
data "coder_parameter" "jetbrains_ides" {
|
||||
@@ -231,8 +264,8 @@ data "coder_parameter" "jetbrains_ides" {
|
||||
dynamic "option" {
|
||||
for_each = var.options
|
||||
content {
|
||||
icon = var.ide_config[option.value].icon
|
||||
name = var.ide_config[option.value].name
|
||||
icon = var.ide_config != null ? coalesce(var.ide_config[option.value].icon, local.ide_metadata[option.value].icon) : local.ide_metadata[option.value].icon
|
||||
name = var.ide_config != null ? coalesce(var.ide_config[option.value].name, local.ide_metadata[option.value].name) : local.ide_metadata[option.value].name
|
||||
value = option.value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ The VSCode Desktop Core module is a building block for modules that need to expo
|
||||
```tf
|
||||
module "vscode-desktop-core" {
|
||||
source = "registry.coder.com/coder/vscode-desktop-core/coder"
|
||||
version = "1.0.2"
|
||||
version = "1.1.0"
|
||||
|
||||
agent_id = var.agent_id
|
||||
|
||||
|
||||
@@ -3,6 +3,11 @@ import {
|
||||
runTerraformApply,
|
||||
runTerraformInit,
|
||||
testRequiredVariables,
|
||||
runContainer,
|
||||
execContainer,
|
||||
removeContainer,
|
||||
findResourceInstance,
|
||||
readFileContainer,
|
||||
} from "~test";
|
||||
|
||||
// hardcoded coder_app name in main.tf
|
||||
@@ -16,6 +21,7 @@ const defaultVariables = {
|
||||
coder_app_display_name: "VS Code Desktop",
|
||||
|
||||
protocol: "vscode",
|
||||
config_dir: "$HOME/.vscode",
|
||||
};
|
||||
|
||||
describe("vscode-desktop-core", async () => {
|
||||
@@ -134,4 +140,41 @@ describe("vscode-desktop-core", async () => {
|
||||
expect(coder_app?.instances[0].attributes.group).toBe("web-app-group");
|
||||
});
|
||||
});
|
||||
|
||||
it("writes mcp_config.json when mcp_config variable provided", async () => {
|
||||
const id = await runContainer("alpine");
|
||||
|
||||
try {
|
||||
const mcp_config = JSON.stringify({
|
||||
servers: { demo: { url: "http://localhost:1234" } },
|
||||
});
|
||||
|
||||
const state = await runTerraformApply(import.meta.dir, {
|
||||
...defaultVariables,
|
||||
|
||||
mcp_config,
|
||||
});
|
||||
|
||||
const script = findResourceInstance(
|
||||
state,
|
||||
"coder_script",
|
||||
"vscode-desktop-mcp",
|
||||
).script;
|
||||
|
||||
const resp = await execContainer(id, ["sh", "-c", script]);
|
||||
if (resp.exitCode !== 0) {
|
||||
console.log(resp.stdout);
|
||||
console.log(resp.stderr);
|
||||
}
|
||||
expect(resp.exitCode).toBe(0);
|
||||
|
||||
const content = await readFileContainer(
|
||||
id,
|
||||
`${defaultVariables.config_dir.replace("$HOME", "/root")}/mcp_config.json`,
|
||||
);
|
||||
expect(content).toBe(mcp_config);
|
||||
} finally {
|
||||
await removeContainer(id);
|
||||
}
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
@@ -26,11 +26,22 @@ variable "open_recent" {
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "mcp_config" {
|
||||
type = map(any)
|
||||
description = "MCP server configuration for the IDE. When set, writes mcp_config.json in var.config_dir."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "protocol" {
|
||||
type = string
|
||||
description = "The URI protocol the IDE."
|
||||
}
|
||||
|
||||
variable "config_dir" {
|
||||
type = string
|
||||
description = "The path of the IDE's configuration folder."
|
||||
}
|
||||
|
||||
variable "coder_app_icon" {
|
||||
type = string
|
||||
description = "The icon of the coder_app."
|
||||
@@ -85,21 +96,36 @@ resource "coder_app" "vscode-desktop" {
|
||||
data.coder_workspace.me.access_url,
|
||||
"&token=$SESSION_TOKEN",
|
||||
])
|
||||
}
|
||||
|
||||
/*
|
||||
url = join("", [
|
||||
"vscode://coder.coder-remote/open",
|
||||
"?owner=${data.coder_workspace_owner.me.name}",
|
||||
"&workspace=${data.coder_workspace.me.name}",
|
||||
var.folder != "" ? join("", ["&folder=", var.folder]) : "",
|
||||
var.open_recent ? "&openRecent" : "",
|
||||
"&url=${data.coder_workspace.me.access_url}",
|
||||
"&token=$SESSION_TOKEN",
|
||||
])
|
||||
*/
|
||||
resource "coder_script" "vscode-desktop-mcp" {
|
||||
agent_id = var.agent_id
|
||||
count = var.mcp_config != null ? 1 : 0
|
||||
|
||||
icon = var.coder_app_icon
|
||||
display_name = "${var.coder_app_display_name} MCP"
|
||||
|
||||
run_on_start = true
|
||||
start_blocks_login = false
|
||||
|
||||
script = <<-EOT
|
||||
#!/bin/sh
|
||||
set -euo pipefail
|
||||
|
||||
IDE_CONFIG_FOLDER="${var.config_dir}"
|
||||
IDE_MCP_CONFIG_PATH="$IDE_CONFIG_FOLDER/mcp_config.json"
|
||||
|
||||
mkdir -p "$IDE_CONFIG_FOLDER"
|
||||
|
||||
echo -n "${base64encode(jsonencode(var.mcp_config))}" | base64 -d > "$IDE_MCP_CONFIG_PATH"
|
||||
chmod 600 "$IDE_MCP_CONFIG_PATH"
|
||||
|
||||
# Cursor/Windsurf use this config instead, no need for chmod as symlinks do not have modes
|
||||
ln -s "$IDE_MCP_CONFIG_PATH" "$IDE_CONFIG_FOLDER/mcp.json"
|
||||
EOT
|
||||
}
|
||||
|
||||
output "ide_uri" {
|
||||
value = coder_app.vscode-desktop.url
|
||||
description = "IDE URI."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
display_name: Docker RStudio
|
||||
description: Provision Docker containers with RStudio, code-server, and RMarkdown
|
||||
icon: ../../../../.icons/rstudio.svg
|
||||
verified: true
|
||||
tags: [docker, rstudio, r, rmarkdown, code-server]
|
||||
---
|
||||
|
||||
# R Development on Docker Containers
|
||||
|
||||
Provision Docker containers pre-configured for R development as [Coder workspaces](https://coder.com/docs/workspaces) with this template.
|
||||
|
||||
Each workspace comes with:
|
||||
|
||||
- **RStudio Server** — full-featured R IDE in the browser.
|
||||
- **code-server** — VS Code in the browser for general editing.
|
||||
- **RMarkdown** — author reproducible documents, reports, and presentations.
|
||||
|
||||
The workspace is based on the [rocker/rstudio](https://rocker-project.org/) image, which ships R and RStudio Server pre-installed.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Infrastructure
|
||||
|
||||
#### Running Coder inside Docker
|
||||
|
||||
If you installed Coder as a container within Docker, you will have to do the following things:
|
||||
|
||||
- Make the Docker socket available to the container
|
||||
- **(recommended) Mount `/var/run/docker.sock` via `--mount`/`volume`**
|
||||
- _(advanced) Restrict the Docker socket via https://github.com/Tecnativa/docker-socket-proxy_
|
||||
- Set `--group-add`/`group_add` to the GID of the Docker group on the **host** machine
|
||||
- You can get the GID by running `getent group docker` on the **host** machine
|
||||
|
||||
#### Running Coder outside of Docker
|
||||
|
||||
If you installed Coder as a system package, the VM you run Coder on must have a running Docker socket and the `coder` user must be added to the Docker group:
|
||||
|
||||
```sh
|
||||
# Add coder user to Docker group
|
||||
sudo adduser coder docker
|
||||
|
||||
# Restart Coder server
|
||||
sudo systemctl restart coder
|
||||
|
||||
# Test Docker
|
||||
sudo -u coder docker ps
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
This template provisions the following resources:
|
||||
|
||||
- Docker image (built from `build/Dockerfile`, extending `rocker/rstudio` with system dependencies)
|
||||
- Docker container (ephemeral — destroyed on workspace stop)
|
||||
- Docker volume (persistent on `/home/rstudio`)
|
||||
|
||||
When the workspace restarts, tools and files outside `/home/rstudio` are not persisted. The R library path defaults to a subdirectory of the home folder, so installed packages (including RMarkdown) survive restarts.
|
||||
|
||||
> [!NOTE]
|
||||
> This template is designed to be a starting point! Edit the Terraform to extend it for your use case.
|
||||
|
||||
## Customization
|
||||
|
||||
### Changing the R version
|
||||
|
||||
Set the `rstudio_version` variable to any valid [rocker/rstudio tag](https://hub.docker.com/r/rocker/rstudio/tags) (for example `4.4.2`, `4.3`, or `latest`).
|
||||
|
||||
### Installing additional R packages
|
||||
|
||||
R packages are pre-installed via the `build/Dockerfile` so they are available immediately when the workspace starts. To add more packages, add `install.packages()` calls to the Dockerfile:
|
||||
|
||||
```dockerfile
|
||||
RUN R -e "install.packages(c('tidyverse', 'shiny'))"
|
||||
```
|
||||
|
||||
The image is pre-configured to use [Posit Package Manager](https://packagemanager.posit.co/) which provides pre-compiled binary packages for fast installation. Packages installed at build time avoid long startup delays from compiling from source on every workspace start.
|
||||
|
||||
### Adding system dependencies
|
||||
|
||||
The `build/Dockerfile` extends the `rocker/rstudio` base image with system packages required by modules (e.g. `curl` for code-server, `cmake` for R package compilation). If you add modules that need additional system-level tools, add them to the `Dockerfile`:
|
||||
|
||||
```dockerfile
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y \
|
||||
curl \
|
||||
cmake \
|
||||
your-package-here \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
```
|
||||
|
||||
### Adding LaTeX for PDF rendering
|
||||
|
||||
RMarkdown can render PDF output when LaTeX is available. Add the following to the startup script to install TinyTeX:
|
||||
|
||||
```sh
|
||||
R --quiet -e "if (!require('tinytex', quietly = TRUE)) { install.packages('tinytex', repos = 'https://cloud.r-project.org'); tinytex::install_tinytex() }"
|
||||
```
|
||||
@@ -0,0 +1,12 @@
|
||||
ARG RSTUDIO_VERSION=4
|
||||
FROM rocker/rstudio:${RSTUDIO_VERSION}
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y \
|
||||
curl \
|
||||
cmake \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN R -e "install.packages('rmarkdown')"
|
||||
|
||||
RUN echo "auth-minimum-user-id=0" >>/etc/rstudio/rserver.conf
|
||||
@@ -0,0 +1,244 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
}
|
||||
docker = {
|
||||
source = "kreuzwerker/docker"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
locals {
|
||||
username = data.coder_workspace_owner.me.name
|
||||
}
|
||||
|
||||
variable "docker_socket" {
|
||||
default = ""
|
||||
description = "(Optional) Docker socket URI"
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "rstudio_version" {
|
||||
default = "4"
|
||||
description = "The rocker/rstudio image tag to use (e.g. 4, 4.4, 4.4.2)"
|
||||
type = string
|
||||
}
|
||||
|
||||
provider "docker" {
|
||||
# Defaulting to null if the variable is an empty string lets us
|
||||
# have an optional variable without having to set our own default.
|
||||
host = var.docker_socket != "" ? var.docker_socket : null
|
||||
}
|
||||
|
||||
data "coder_provisioner" "me" {}
|
||||
data "coder_workspace" "me" {}
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
resource "coder_agent" "main" {
|
||||
arch = data.coder_provisioner.me.arch
|
||||
os = "linux"
|
||||
startup_script = <<-EOT
|
||||
set -e
|
||||
|
||||
# Prepare user home with default files on first start.
|
||||
if [ ! -f ~/.init_done ]; then
|
||||
cp -rT /etc/skel ~ 2>/dev/null || true
|
||||
touch ~/.init_done
|
||||
fi
|
||||
|
||||
# Start RStudio Server. The rocker/rstudio image ships the
|
||||
# server pre-installed. We disable authentication because
|
||||
# the Coder proxy handles access control.
|
||||
if command -v rserver > /dev/null 2>&1; then
|
||||
sudo rserver \
|
||||
--server-daemonize=0 \
|
||||
--auth-none=1 \
|
||||
--www-port=8787 \
|
||||
--server-user=rstudio > /tmp/rserver.log 2>&1 &
|
||||
elif [ -x /usr/lib/rstudio-server/bin/rserver ]; then
|
||||
sudo /usr/lib/rstudio-server/bin/rserver \
|
||||
--server-daemonize=0 \
|
||||
--auth-none=1 \
|
||||
--www-port=8787 \
|
||||
--server-user=rstudio > /tmp/rserver.log 2>&1 &
|
||||
fi
|
||||
EOT
|
||||
|
||||
# These environment variables allow you to make Git commits
|
||||
# right away after creating a workspace. They take precedence
|
||||
# over configuration in ~/.gitconfig. Remove this block if
|
||||
# you prefer to configure Git manually or via dotfiles.
|
||||
env = {
|
||||
GIT_AUTHOR_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
|
||||
GIT_AUTHOR_EMAIL = "${data.coder_workspace_owner.me.email}"
|
||||
GIT_COMMITTER_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
|
||||
GIT_COMMITTER_EMAIL = "${data.coder_workspace_owner.me.email}"
|
||||
}
|
||||
|
||||
metadata {
|
||||
display_name = "CPU Usage"
|
||||
key = "0_cpu_usage"
|
||||
script = "coder stat cpu"
|
||||
interval = 10
|
||||
timeout = 1
|
||||
}
|
||||
|
||||
metadata {
|
||||
display_name = "RAM Usage"
|
||||
key = "1_ram_usage"
|
||||
script = "coder stat mem"
|
||||
interval = 10
|
||||
timeout = 1
|
||||
}
|
||||
|
||||
metadata {
|
||||
display_name = "Home Disk"
|
||||
key = "3_home_disk"
|
||||
script = "coder stat disk --path $${HOME}"
|
||||
interval = 60
|
||||
timeout = 1
|
||||
}
|
||||
|
||||
metadata {
|
||||
display_name = "CPU Usage (Host)"
|
||||
key = "4_cpu_usage_host"
|
||||
script = "coder stat cpu --host"
|
||||
interval = 10
|
||||
timeout = 1
|
||||
}
|
||||
|
||||
metadata {
|
||||
display_name = "Memory Usage (Host)"
|
||||
key = "5_mem_usage_host"
|
||||
script = "coder stat mem --host"
|
||||
interval = 10
|
||||
timeout = 1
|
||||
}
|
||||
|
||||
metadata {
|
||||
display_name = "Load Average (Host)"
|
||||
key = "6_load_host"
|
||||
# Get load average scaled by number of cores.
|
||||
script = <<EOT
|
||||
echo "`cat /proc/loadavg | awk '{ print $1 }'` `nproc`" | awk '{ printf "%0.2f", $1/$2 }'
|
||||
EOT
|
||||
interval = 60
|
||||
timeout = 1
|
||||
}
|
||||
|
||||
metadata {
|
||||
display_name = "Swap Usage (Host)"
|
||||
key = "7_swap_host"
|
||||
script = <<EOT
|
||||
free -b | awk '/^Swap/ { printf("%.1f/%.1f", $3/1024.0/1024.0/1024.0, $2/1024.0/1024.0/1024.0) }'
|
||||
EOT
|
||||
interval = 10
|
||||
timeout = 1
|
||||
}
|
||||
}
|
||||
|
||||
# RStudio Server — served through the Coder proxy so users can
|
||||
# open the full RStudio IDE directly from the dashboard.
|
||||
resource "coder_app" "rstudio" {
|
||||
agent_id = coder_agent.main.id
|
||||
slug = "rstudio"
|
||||
display_name = "RStudio"
|
||||
url = "http://localhost:8787"
|
||||
icon = "/icon/rstudio.svg"
|
||||
subdomain = true
|
||||
share = "owner"
|
||||
order = 1
|
||||
}
|
||||
|
||||
# See https://registry.coder.com/modules/coder/code-server
|
||||
module "code-server" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/code-server/coder"
|
||||
|
||||
# This ensures that the latest non-breaking version of the
|
||||
# module gets downloaded. You can also pin the module version
|
||||
# to prevent breaking changes in production.
|
||||
version = "~> 1.0"
|
||||
|
||||
agent_id = coder_agent.main.id
|
||||
order = 2
|
||||
folder = "/home/rstudio"
|
||||
}
|
||||
|
||||
resource "docker_image" "main" {
|
||||
name = "coder-${data.coder_workspace.me.id}-rstudio"
|
||||
build {
|
||||
context = "./build"
|
||||
build_args = {
|
||||
RSTUDIO_VERSION = var.rstudio_version
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "docker_volume" "home_volume" {
|
||||
name = "coder-${data.coder_workspace.me.id}-home"
|
||||
# Protect the volume from being deleted due to changes in
|
||||
# attributes.
|
||||
lifecycle {
|
||||
ignore_changes = all
|
||||
}
|
||||
# Add labels in Docker to keep track of orphan resources.
|
||||
labels {
|
||||
label = "coder.owner"
|
||||
value = data.coder_workspace_owner.me.name
|
||||
}
|
||||
labels {
|
||||
label = "coder.owner_id"
|
||||
value = data.coder_workspace_owner.me.id
|
||||
}
|
||||
labels {
|
||||
label = "coder.workspace_id"
|
||||
value = data.coder_workspace.me.id
|
||||
}
|
||||
# This field becomes outdated if the workspace is renamed but
|
||||
# can be useful for debugging or cleaning out dangling volumes.
|
||||
labels {
|
||||
label = "coder.workspace_name_at_creation"
|
||||
value = data.coder_workspace.me.name
|
||||
}
|
||||
}
|
||||
|
||||
resource "docker_container" "workspace" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
image = docker_image.main.image_id
|
||||
# Uses lower() to avoid Docker restriction on container names.
|
||||
name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}"
|
||||
# Hostname makes the shell more user friendly: rstudio@my-workspace:~$
|
||||
hostname = data.coder_workspace.me.name
|
||||
# Use the docker gateway if the access URL is 127.0.0.1.
|
||||
entrypoint = ["sh", "-c", replace(coder_agent.main.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")]
|
||||
env = ["CODER_AGENT_TOKEN=${coder_agent.main.token}"]
|
||||
host {
|
||||
host = "host.docker.internal"
|
||||
ip = "host-gateway"
|
||||
}
|
||||
volumes {
|
||||
container_path = "/home/rstudio"
|
||||
volume_name = docker_volume.home_volume.name
|
||||
read_only = false
|
||||
}
|
||||
|
||||
# Add labels in Docker to keep track of orphan resources.
|
||||
labels {
|
||||
label = "coder.owner"
|
||||
value = data.coder_workspace_owner.me.name
|
||||
}
|
||||
labels {
|
||||
label = "coder.owner_id"
|
||||
value = data.coder_workspace_owner.me.id
|
||||
}
|
||||
labels {
|
||||
label = "coder.workspace_id"
|
||||
value = data.coder_workspace.me.id
|
||||
}
|
||||
labels {
|
||||
label = "coder.workspace_name"
|
||||
value = data.coder_workspace.me.name
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user