Compare commits

...

21 Commits

Author SHA1 Message Date
DevelopmentCats d638371a85 feat: initial commit for restic 2025-10-20 13:58:22 -05:00
Mathias Fredriksson e34320cb0b feat: add archive module (#422)
This change adds a new `archive` module to the Coder registry. It can be
used to archive user-data from pre-defined locations and restore it as
well.

Here we also explore:

- A new method of passing arrays from Terraform to Bash
- A new method of writing Bash scripts that minimizes the interaction
with terraform interpolation
- Extensive test-suite that not only tests that Terraform options can be
selected, but also the resulting script behaviors

---------

Co-authored-by: Cian Johnston <cian@coder.com>
Co-authored-by: DevCats <christofer@coder.com>
2025-10-17 08:14:56 -05:00
35C4n0r ca7bc42946 feat: update auth setup in codex (#472)
Closes #

## Description

<!-- Briefly describe what this PR does and why -->

## Type of Change

- [ ] New module
- [x] Bug fix
- [x] Feature/enhancement
- [ ] Documentation
- [ ] Other

## Module Information

<!-- Delete this section if not applicable -->

**Path:** `registry/coder-labs/modules/codex`  
**New version:** `v3.0.0`  
**Breaking change:** [X] Yes [ ] No

## Testing & Validation

- [X] Tests pass (`bun test`)
- [X] Code formatted (`bun run fmt`)
- [X] Changes tested locally

## Related Issues

<!-- Link related issues or write "None" if not applicable -->

---------

Co-authored-by: DevCats <christofer@coder.com>
2025-10-16 15:25:57 -05:00
35C4n0r a599302774 feat: amp upgrades for better ux (#390)
Closes #

## Description
- remove default node installation
- users can pass amp versions now
- move env variables to terraform variable (system prompt and ai prompt)

<!-- Briefly describe what this PR does and why -->

## Type of Change

- [ ] New module
- [ ] Bug fix
- [x] Feature/enhancement
- [ ] Documentation
- [ ] Other

## Module Information

<!-- Delete this section if not applicable -->

**Path:** `registry/coder-labs/modules/sourcegraph-amp`  
**New version:** `v2.0.0`  
**Breaking change:** [x] Yes [ ] No

## Testing & Validation

- [x] Tests pass (`bun test`)
- [x] Code formatted (`bun run fmt`)
- [x] Changes tested locally

## Related Issues

<!-- Link related issues or write "None" if not applicable -->

---------

Co-authored-by: DevCats <christofer@coder.com>
Co-authored-by: Atif Ali <me@matifali.dev>
2025-10-16 15:21:17 -05:00
DevCats ff09c415e8 feat: change tf test and validation to use paths-filter (#483)
## Description

<!-- Briefly describe what this PR does and why -->

## Type of Change

- [ ] New module
- [ ] New template
- [ ] Bug fix
- [ ] Feature/enhancement
- [ ] Documentation
- [ ] Other

## Module Information

<!-- Delete this section if not applicable -->

**Path:** `registry/[namespace]/modules/[module-name]`  
**New version:** `v1.0.0`  
**Breaking change:** [ ] Yes [ ] No

## Template Information

<!-- Delete this section if not applicable -->

**Path:** `registry/[namespace]/templates/[template-name]`

## Testing & Validation

- [ ] Tests pass (`bun test`)
- [ ] Code formatted (`bun fmt`)
- [ ] Changes tested locally

## Related Issues

<!-- Link related issues or write "None" if not applicable -->
2025-10-16 14:21:03 -05:00
DevCats 90873e8009 ci: update CI workflow to run TypeScript tests with new script (#480) 2025-10-15 14:03:12 -05:00
DevCats 2168360195 fix: add folder to all Agent Modules (#481)
## Description

Make sure folder is passed to agentapi in all Agent modules.
<!-- Briefly describe what this PR does and why -->

## Type of Change

- [ ] New module
- [ ] New template
- [X] Bug fix
- [ ] Feature/enhancement
- [ ] Documentation
- [ ] Other

## Module Information

**Path:** `registry/coder-labs/modules/auggie`  
**New version:** `v0.2.1`  
**Breaking change:** [ ] Yes [X] No

**Path:** `registry/coder-labs/modules/cursor-cli`  
**New version:** `v0.2.1`  
**Breaking change:** [ ] Yes [X] No

**Path:** `registry/coder-labs/modules/gemini`  
**New version:** `v2.1.1`  
**Breaking change:** [ ] Yes [X] No

**Path:** `registry/coder-labs/modules/sourcegraph_amp`  
**New version:** `v1.1.1`  
**Breaking change:** [ ] Yes [X] No

## Testing & Validation

- [X] Tests pass (`bun test`)
- [X] Code formatted (`bun fmt`)
- [X] Changes tested locally

## Related Issues

<!-- Link related issues or write "None" if not applicable -->
2025-10-15 12:20:08 -05:00
Riajul Islam da5a2ba6a8 feat(git-clone module): added post_clone_script. (#357)
Co-authored-by: DevCats <christofer@coder.com>
Co-authored-by: Atif Ali <atif@coder.com>
2025-10-15 12:53:17 +00:00
Anas 63cad25954 fix(amazon-q): pass workdir variable into agentapi folder variable (#478)
Co-authored-by: DevCats <christofer@coder.com>
2025-10-15 17:44:58 +05:00
Hulto cd759bd9a1 goose module: pass folder along to agentapi (#412)
Co-authored-by: DevCats <christofer@coder.com>
2025-10-15 17:44:18 +05:00
DevCats 54a7bb0001 docs: add usage examples for bedrock and vertex (#431)
Closes #

## Description

Adds Usage Examples for Vertex and Bedrock as described in the linked
documentation.

## Type of Change

- [ ] New module
- [ ] Bug fix
- [ ] Feature/enhancement
- [X] Documentation
- [ ] Other

## Module Information

<!-- Delete this section if not applicable -->

**Path:** `registry/coder/modules/claude-code`  
**New version:** `v3.0.1`  
**Breaking change:** [ ] Yes [X] No

## Testing & Validation

- [X] Tests pass (`bun test`)
- [X] Code formatted (`bun run fmt`)
- [ ] Changes tested locally

## Related Issues

<!-- Link related issues or write "None" if not applicable -->
2025-10-14 12:06:32 -05:00
Matt Hazinski 50f4d5388b fix(codex): pass folder variable to agentapi module (#477)
## Description

The folder variable was not being passed from the codex module to the
agentapi module, causing agentapi to use its default value of
`/home/coder` instead of the user-specified folder path.

This resulted in permission errors when the codex module tried to create
directories in `/home/coder` when users specified a different folder
like `/home/matt/foo`.

Fix by adding `folder = var.folder` to the agentapi module invocation.

## Type of Change

- [ ] New module
- [ ] New template
- [x] Bug fix
- [ ] Feature/enhancement
- [ ] Documentation
- [ ] Other

## Module Information

Path: registry/coder-labs/modules/codex
New version: v2.1.1
Breaking change: [ ] Yes [X] No

## Testing & Validation

- [x] Tests pass (`bun test`)
- [x] Code formatted (`bun fmt`)
- [x] Changes tested locally

## Related Issues
Fixes https://github.com/coder/registry/issues/476

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: DevelopmentCats <christofer@coder.com>
2025-10-14 16:49:52 +00:00
dependabot[bot] 36943d1dfb chore(deps): bump crate-ci/typos from 1.37.2 to 1.38.1 in the github-actions group (#475)
Bumps the github-actions group with 1 update:
[crate-ci/typos](https://github.com/crate-ci/typos).

Updates `crate-ci/typos` from 1.37.2 to 1.38.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/crate-ci/typos/releases">crate-ci/typos's
releases</a>.</em></p>
<blockquote>
<h2>v1.38.1</h2>
<h2>[1.38.1] - 2025-10-07</h2>
<h3>Fixes</h3>
<ul>
<li>Ignore common golang identifiers</li>
</ul>
<h2>v1.38.0</h2>
<h2>[1.38.0] - 2025-10-06</h2>
<h3>Features</h3>
<ul>
<li>Update type list</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Don't correct <code>typ</code></li>
<li>Consistently error on unused config fields</li>
</ul>
<h2>v1.37.3</h2>
<h2>[1.37.3] - 2025-10-06</h2>
<h3>Fixes</h3>
<ul>
<li>Don't correct <code>PN</code> for <code>bitbake</code> file
types</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/crate-ci/typos/blob/master/CHANGELOG.md">crate-ci/typos's
changelog</a>.</em></p>
<blockquote>
<h2>[1.38.1] - 2025-10-07</h2>
<h3>Fixes</h3>
<ul>
<li>Ignore common golang identifiers</li>
</ul>
<h2>[1.38.0] - 2025-10-06</h2>
<h3>Features</h3>
<ul>
<li>Update type list</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Don't correct <code>typ</code></li>
<li>Consistently error on unused config fields</li>
</ul>
<h2>[1.37.3] - 2025-10-06</h2>
<h3>Fixes</h3>
<ul>
<li>Don't correct <code>PN</code> for <code>bitbake</code> file
types</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/crate-ci/typos/commit/80c8a4945eec0f6d464eaf9e65ed98ef085283d1"><code>80c8a49</code></a>
chore: Release</li>
<li><a
href="https://github.com/crate-ci/typos/commit/c1008ce1b695c69fa611c3a79c32852be029709a"><code>c1008ce</code></a>
docs: Update changelog</li>
<li><a
href="https://github.com/crate-ci/typos/commit/62a3b5083afa59e8054f76ff5dbb94bb676ce5e4"><code>62a3b50</code></a>
Merge pull request <a
href="https://redirect.github.com/crate-ci/typos/issues/1398">#1398</a>
from ccoveille-forks/go-exclusions</li>
<li><a
href="https://github.com/crate-ci/typos/commit/e6bedbde77058052de3f00d82a67284618385615"><code>e6bedbd</code></a>
fix(config): Add some Go exclusions</li>
<li><a
href="https://github.com/crate-ci/typos/commit/90cacd60e824aaf9adff4afa0d6582f52631bc6d"><code>90cacd6</code></a>
docs(ref): Speak to glob ambiguity</li>
<li><a
href="https://github.com/crate-ci/typos/commit/b81b12ea1b8702b57e1a917e5a7bfc26d46c21e9"><code>b81b12e</code></a>
docs(ref): Clarify directories are not spell checked</li>
<li><a
href="https://github.com/crate-ci/typos/commit/eaf25df9941e5b6a2f145729a76b06af9eab44ca"><code>eaf25df</code></a>
docs(ref): Speak to locale's behavior</li>
<li><a
href="https://github.com/crate-ci/typos/commit/a9735e2e141b9a8f08340e41aac57b52805ae185"><code>a9735e2</code></a>
docs(ref): Provide identifier/word config examples</li>
<li><a
href="https://github.com/crate-ci/typos/commit/3c14191fcc71bad3e87e231c86a6d3a7876ae8a2"><code>3c14191</code></a>
docs(ref): Talk about include lists</li>
<li><a
href="https://github.com/crate-ci/typos/commit/d0f81dc972d4fc9862590daafb26ee03a2dbfda5"><code>d0f81dc</code></a>
docs(ref): Re-organize help more like cargo</li>
<li>Additional commits viewable in <a
href="https://github.com/crate-ci/typos/compare/v1.37.2...v1.38.1">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=crate-ci/typos&package-manager=github_actions&previous-version=1.37.2&new-version=1.38.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-13 07:40:01 -05:00
greg-the-coder e7d705bf98 Fixes from AWS Workshop testing (#428)
Closes #

## Description

Changes to code-server and jetbrains modules that were not caught during
initial unit-testing, that appear to be related to older versions of the
modules or recent changes.

## Type of Change

- [ ] New module
- [x] Bug fix
- [ ] Feature/enhancement
- [ ] Documentation
- [ ] Other

## Testing & Validation

- [ ] Tests pass (`bun test`)
- [ ] Code formatted (`bun run fmt`)
- [x] Changes tested locally

## Related Issues

None

---------

Co-authored-by: DevelopmentCats <christofer@coder.com>
2025-10-10 06:27:18 +05:00
Atif Ali 898219b16b Enhance PR template with template information section (#474) 2025-10-09 15:50:38 +00:00
chgl fc071e0930 refactor: refactored get_http_dir (#360)
Closes #

## Description

<!-- Briefly describe what this PR does and why -->

I just couldn't get the script to execute properly in its current form.
I saw e.g.

```console
[[: 1989{#d[@]}: syntax error: invalid arithmetic operator (error token is "{#d[@]}")
```

when trying to run the script locally. (GNU bash, version
5.2.21(1)-release (x86_64-pc-linux-gnu)).

This uses a likely simpler bash script, but requires both grep and awk.

## Type of Change

- [ ] New module
- [x] Bug fix
- [ ] Feature/enhancement
- [ ] Documentation
- [ ] Other

## Module Information

<!-- Delete this section if not applicable -->

**Path:** `registry/coder/modules/kasmvnc`  
**New version:** `v1.2.3`  
**Breaking change:** [ ] Yes [x] No

## Testing & Validation

- [ ] Tests pass (`bun test`)
- [ ] Code formatted (`bun run fmt`)
- [ ] Changes tested locally

## Related Issues

<!-- Link related issues or write "None" if not applicable -->

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Atif Ali <atif@coder.com>
Co-authored-by: DevCats <christofer@coder.com>
2025-10-09 09:36:14 -05:00
DevCats d516aff908 chore: set verified to false and bump to 1.0.1 (#473)
## Description

Removes verified status from nexus module.
<!-- Briefly describe what this PR does and why -->

## Type of Change

- [ ] New module
- [ ] Bug fix
- [ ] Feature/enhancement
- [ ] Documentation
- [X] Other

## Module Information

<!-- Delete this section if not applicable -->

**Path:** `registry/mavrickrishi/modules/nexus-repository`  
**New version:** `v1.0.1`  
**Breaking change:** [ ] Yes [X] No

## Testing & Validation

- [X] Tests pass (`bun test`)
- [X] Code formatted (`bun run fmt`)
- [X] Changes tested locally
2025-10-09 08:13:06 -05:00
DevCats ccdca6daf5 chore: update CONTRIBUTION docs to explain both tests, and update CI for both tests (#384)
Closes #383 

## Description

- Update CONTRIBUTION.md to elaborate on ts and tf tests
- Add ./scripts/terraform_test_all.sh to CI for ts tests

<!-- Briefly describe what this PR does and why -->

## Type of Change

- [ ] New module
- [ ] Bug fix
- [ ] Feature/enhancement
- [X] Documentation
- [X] Other

## Testing & Validation

- [ ] Tests pass (`bun test`)
- [X] Code formatted (`bun run fmt`)
- [ ] Changes tested locally

---------

Co-authored-by: Atif Ali <atif@coder.com>
2025-10-09 12:42:07 +00:00
Rishi Mondal ce039f64df Add Sonatype Nexus repository integration module (#262)
# Add Sonatype Nexus Repository Integration Module

## Summary
Implements a Coder module for Sonatype Nexus Repository Manager
integration that automatically configures Maven, npm, PyPI, and Docker
registries for development workspaces.

## Demo Video & Screenshots

https://github.com/user-attachments/assets/2c51f229-d34d-483b-a0e9-f4e0d79332c2

![Nexus Repository
Integration](https://github.com/user-attachments/assets/1a778a8f-0e48-40f2-ae0f-5b8d5d5ce849)

## Features
-  **Maven Support**: Automatic `settings.xml` configuration
-  **npm Support**: Automatic `.npmrc` configuration with scoped
packages
-  **PyPI Support**: Automatic `pip.conf` configuration
-  **Docker Support**: Registry authentication setup
-  **Flexible Configuration**: Support for multiple repositories per
package manager
-  **Secure Credentials**: API token and password support
-  **Username Options**: Configurable username field (username or
email)

## Nexus Repository Manager Requirements

### Version Requirements
**Yes, this module requires Nexus Repository Manager Pro version** for
full functionality, though basic features work with the Community
Edition (OSS).

### Supported Authentication Methods
This module supports **4 authentication methods**:

1. **User Token Authentication** (Recommended - Pro only)
   - Enhanced security with two-part tokens
   - Ideal for CI/CD and automated environments
   - Requires `nx-usertoken-current` privilege

2. **API Token Authentication** (Pro only)
   - Single-use access tokens via REST API
   - Programmatic token generation and management

3. **Basic Authentication** (OSS & Pro)
   - Standard HTTP Basic Auth with username/password
   - Works with both OSS and Pro versions

4. **Base64 Encoded Credentials** (OSS & Pro)  
   - Base64 encoded `username:password` format
   - Compatible with npm and other package managers

### Testing Instructions

#### Prerequisites
- Nexus Repository Manager instance (OSS or Pro)
- Admin access to configure repositories
- Test repositories for each package manager you want to test

#### Setup Test Environment
1. **Create Test Repositories** in your Nexus instance:
   - Maven: `maven-public`, `maven-releases` 
   - npm: `npm-public`, `@company:npm-private`
   - PyPI: `pypi-public`, `pypi-private`
   - Docker: `docker-public`, `docker-private`

2. **Configure Authentication**:
   - For Pro: Generate user tokens via UI (User menu → User Token)
   - For OSS: Use username/password or base64 encoded credentials
   - Set up appropriate permissions for test repositories

3. **Test the Module**:
   ```hcl
   module "nexus" {
     source         = "registry.coder.com/mavrickrishi/nexus/coder"
     version        = "1.0.0"
     agent_id       = coder_agent.main.id
     nexus_url      = "https://your-nexus-instance.com"
     nexus_password = var.nexus_api_token  # or password
     package_managers = {
       maven  = ["maven-public", "maven-releases"]
       npm    = ["npm-public", "@company:npm-private"]
       pypi   = ["pypi-public", "pypi-private"]
       docker = ["docker-public", "docker-private"]
     }
   }
   ```

4. **Verify Configuration**:
   - Check generated config files in workspace
   - Test package installation from configured repositories
   - Verify authentication works for each package manager

#### EC2 Deployment Testing
Tested by deploying on EC2 instance with:
- Ubuntu 22.04 LTS
- Nexus Repository Manager Pro
- All package managers (Maven, npm, PyPI, Docker)
- Both token and basic authentication methods

## Usage Example
```hcl
module "nexus" {
  source         = "registry.coder.com/mavrickrishi/nexus/coder"
  version        = "1.0.0"
  agent_id       = coder_agent.main.id
  nexus_url      = "https://nexus.company.com"
  nexus_password = var.nexus_api_token
  package_managers = {
    maven  = ["maven-public", "maven-releases"]
    npm    = ["npm-public", "@company:npm-private"]
    pypi   = ["pypi-public", "pypi-private"]
    docker = ["docker-public", "docker-private"]
  }
}
```

## Testing
-  11 comprehensive tests covering all functionality
-  Variable validation tests
-  Package manager configuration tests
-  Error handling tests
-  All tests passing
-  EC2 deployment tested

## Files Added
- `registry/mavrickrishi/modules/nexus/main.tf` - Main module
configuration
- `registry/mavrickrishi/modules/nexus/README.md` - Complete
documentation
- `registry/mavrickrishi/modules/nexus/main.test.ts` - Test suite

## Checklist
- [x] Module follows existing patterns and conventions
- [x] Comprehensive test coverage (11 tests)
- [x] Complete documentation with examples
- [x] Input validation and error handling
- [x] Secure credential handling
- [x] All tests passing
- [x] Demo video included
- [x] Screenshots added
- [x] Testing instructions provided
- [x] Authentication methods documented
- [x] EC2 deployment tested

Closes #202
/claim #202

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Atif Ali <me@matifali.dev>
Co-authored-by: DevCats <christofer@coder.com>
Co-authored-by: DevCats <chris@dualriver.com>
2025-10-09 07:31:43 -05:00
DevCats 8acda84dd7 chore: update icons for auto-start-dev-server module (#471)
## Description

Adds icons for module, and update all refrences.

PR for Site Icon's Addition: https://github.com/coder/coder/pull/20219

## Type of Change

- [ ] New module
- [X] Bug fix
- [ ] Feature/enhancement
- [ ] Documentation
- [ ] Other

## Module Information

<!-- Delete this section if not applicable -->

**Path:** `registry/mavrickrishi/modules/auto-start-dev-server`  
**New version:** `v1.0.1`  
**Breaking change:** [ ] Yes [ ] No

## Testing & Validation

- [X] Tests pass (`bun test`)
- [X] Code formatted (`bun run fmt`)
- [X] Changes tested locally
2025-10-08 13:20:45 -05:00
Atif Ali 76c1299968 docs: upgrade alert style to a GFM style tip for JetBrains Gateway (#468) 2025-10-08 06:39:54 +00:00
62 changed files with 4865 additions and 326 deletions
+8 -3
View File
@@ -1,5 +1,3 @@
Closes #
## Description
<!-- Briefly describe what this PR does and why -->
@@ -7,6 +5,7 @@ Closes #
## Type of Change
- [ ] New module
- [ ] New template
- [ ] Bug fix
- [ ] Feature/enhancement
- [ ] Documentation
@@ -20,10 +19,16 @@ Closes #
**New version:** `v1.0.0`
**Breaking change:** [ ] Yes [ ] No
## Template Information
<!-- Delete this section if not applicable -->
**Path:** `registry/[namespace]/templates/[template-name]`
## Testing & Validation
- [ ] Tests pass (`bun test`)
- [ ] Code formatted (`bun run fmt`)
- [ ] Code formatted (`bun fmt`)
- [ ] Changes tested locally
## Related Issues
+36 -2
View File
@@ -13,6 +13,26 @@ jobs:
steps:
- name: Check out code
uses: actions/checkout@v5
- name: Detect changed files
uses: dorny/paths-filter@v3
id: filter
with:
list-files: shell
filters: |
shared:
- 'test/**'
- 'package.json'
- 'bun.lock'
- 'bunfig.toml'
- 'tsconfig.json'
- '.github/workflows/ci.yaml'
- 'scripts/ts_test_auto.sh'
- 'scripts/terraform_test_all.sh'
- 'scripts/terraform_validate.sh'
modules:
- 'registry/**/modules/**'
all:
- '**'
- name: Set up Terraform
uses: coder/coder/.github/actions/setup-tf@main
- name: Set up Bun
@@ -27,8 +47,22 @@ jobs:
- name: Install dependencies
run: bun install
- name: Run TypeScript tests
run: bun test
env:
ALL_CHANGED_FILES: ${{ steps.filter.outputs.all_files }}
SHARED_CHANGED: ${{ steps.filter.outputs.shared }}
MODULE_CHANGED_FILES: ${{ steps.filter.outputs.modules_files }}
run: bun tstest
- name: Run Terraform tests
env:
ALL_CHANGED_FILES: ${{ steps.filter.outputs.all_files }}
SHARED_CHANGED: ${{ steps.filter.outputs.shared }}
MODULE_CHANGED_FILES: ${{ steps.filter.outputs.modules_files }}
run: bun tftest
- name: Run Terraform Validate
env:
ALL_CHANGED_FILES: ${{ steps.filter.outputs.all_files }}
SHARED_CHANGED: ${{ steps.filter.outputs.shared }}
MODULE_CHANGED_FILES: ${{ steps.filter.outputs.modules_files }}
run: bun terraform-validate
validate-style:
name: Check for typos and unformatted code
@@ -48,7 +82,7 @@ jobs:
- name: Validate formatting
run: bun fmt:ci
- name: Check for typos
uses: crate-ci/typos@v1.37.2
uses: crate-ci/typos@v1.38.1
with:
config: .github/typos.toml
validate-readme-files:
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="512pt" height="512pt" version="1.1" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<path d="m500.48 262.2-48.18 73.984c-0.73438 1.1367-2 1.8242-3.3555 1.8242-1.3516 0-2.6172-0.6875-3.3516-1.8242l-48.129-73.984c-0.78125-1.2227-0.83594-2.7773-0.14453-4.0547 0.69141-1.2734 2.0195-2.0742 3.4727-2.0898h24.781c-0.007813-29.523-7.7188-58.531-22.375-84.156-14.652-25.629-35.742-46.988-61.184-61.969-2.3711-1.3633-3.8633-3.8594-3.9453-6.5938-0.085937-2.7305 1.2539-5.3125 3.5352-6.8203l27.035-17.613c3.4766-2.3633 8.043-2.3633 11.52 0 28.473 19.934 51.723 46.441 67.773 77.27 16.051 30.828 24.434 65.074 24.438 99.832h24.781c1.4688 0 2.8203 0.80859 3.5156 2.1055 0.69531 1.293 0.62109 2.8633-0.1875 4.0898zm-85.043 79.359c-1.5078-2.2812-4.0898-3.6211-6.8203-3.5391-2.7344 0.085937-5.2305 1.5781-6.5938 3.9492-14.965 25.434-36.305 46.523-61.914 61.188-25.609 14.664-54.602 22.391-84.109 22.422v-24.781c-0.011719-1.4531-0.8125-2.7812-2.0898-3.4727-1.2773-0.69141-2.832-0.63672-4.0547 0.14453l-74.035 47.977c-1.1367 0.73438-1.8242 1.9961-1.8242 3.3516s0.6875 2.6172 1.8242 3.3555l73.984 48.18c1.2227 0.78125 2.7773 0.83594 4.0547 0.14453 1.2734-0.69141 2.0742-2.0234 2.0898-3.4727v-24.68c34.734-0.015624 68.957-8.3984 99.766-24.441 30.812-16.039 57.301-39.27 77.23-67.719 2.3672-3.4766 2.3672-8.043 0-11.52zm-245.45 60.52c-25.434-14.977-46.516-36.328-61.172-61.945-14.652-25.617-22.371-54.617-22.387-84.129h24.781c1.4531-0.011719 2.7812-0.8125 3.4727-2.0898 0.69141-1.2773 0.63672-2.832-0.14453-4.0547l-47.977-74.035c-0.73438-1.1367-1.9961-1.8242-3.3516-1.8242s-2.6172 0.6875-3.3555 1.8242l-48.332 73.984c-0.80859 1.2266-0.88281 2.7969-0.1875 4.0898 0.69531 1.2969 2.0469 2.1055 3.5156 2.1055h24.781c0.015625 34.734 8.3984 68.957 24.438 99.766 16.043 30.812 39.273 57.301 67.723 77.234 3.4766 2.3633 8.043 2.3633 11.52 0l27.086-17.664c2.2109-1.5195 3.4961-4.0625 3.4141-6.7422-0.082032-2.6836-1.5234-5.1406-3.8242-6.5195zm92.16-390.5c-1.2227-0.78125-2.7773-0.83594-4.0547-0.14453-1.2773 0.69141-2.0781 2.0195-2.0898 3.4727v24.73c-34.734 0.015625-68.957 8.3984-99.766 24.438-30.812 16.043-57.301 39.273-77.234 67.723-2.3633 3.4766-2.3633 8.043 0 11.52l17.664 27.086c1.5078 2.2812 4.0898 3.6211 6.8242 3.5352 2.7305-0.082032 5.2266-1.5742 6.5898-3.9453 14.965-25.41 36.289-46.48 61.879-61.133 25.59-14.652 54.555-22.383 84.043-22.426v24.781c0.011719 1.4531 0.8125 2.7812 2.0898 3.4727 1.2773 0.69141 2.832 0.63672 4.0547-0.14453l74.035-47.977c1.1367-0.73438 1.8242-1.9961 1.8242-3.3516s-0.6875-2.6172-1.8242-3.3555zm-6.1445 210.23c-9.0703 0-17.77 3.6055-24.184 10.02-6.4141 6.4141-10.02 15.113-10.02 24.184s3.6055 17.77 10.02 24.184c6.4141 6.4141 15.113 10.02 24.184 10.02s17.77-3.6055 24.184-10.02c6.4141-6.4141 10.02-15.113 10.02-24.184s-3.6055-17.77-10.02-24.184c-6.4141-6.4141-15.113-10.02-24.184-10.02zm90.727-26.828-10.344 14.953c4.0039 6.9414 7.0859 14.375 9.1641 22.117l17.973 2.9688c6.543 1.1445 11.316 6.8242 11.316 13.465v15.055c0 6.6406-4.7734 12.32-11.316 13.465l-17.766 3.125v-0.003907c-2.1562 7.6992-5.3086 15.082-9.3711 21.965l10.238 14.797h0.003906c3.8047 5.4375 3.1562 12.82-1.5352 17.512l-10.648 10.648h-0.003906c-4.6914 4.6953-12.074 5.3438-17.508 1.5391l-14.797-10.238v-0.003907c-6.9453 4.0039-14.379 7.0859-22.121 9.1641l-3.0195 18.023c-1.1445 6.543-6.8242 11.316-13.465 11.316h-15.055c-6.6406 0-12.32-4.7734-13.465-11.316l-3.125-17.766h0.003907c-7.7031-2.1758-15.086-5.3398-21.965-9.4219l-14.797 10.238v0.003907c-5.4375 3.8047-12.82 3.1562-17.512-1.5391l-10.648-10.648c-4.6953-4.6914-5.3438-12.074-1.5391-17.512l10.238-14.797h0.003907c-4.0039-6.9414-7.0859-14.375-9.1641-22.117l-18.023-2.9688c-6.543-1.1445-11.316-6.8242-11.316-13.465v-15.055c0-6.6406 4.7734-12.32 11.316-13.465l17.766-3.125v0.003907c2.1562-7.6992 5.3086-15.082 9.3711-21.965l-10.238-14.797h-0.003906c-3.8047-5.4375-3.1562-12.82 1.5352-17.512l10.648-10.648h0.003906c4.6914-4.6953 12.074-5.3438 17.508-1.5391l14.797 10.238v0.003907c6.9453-4.0039 14.379-7.0859 22.121-9.1641l3.0195-18.023c1.1445-6.543 6.8242-11.316 13.465-11.316h15.055c6.6406 0 12.32 4.7734 13.465 11.316l3.125 17.766h-0.003907c7.6992 2.1562 15.082 5.3086 21.965 9.3711l14.797-10.238v-0.003906c5.4375-3.8047 12.82-3.1562 17.512 1.5352l10.648 10.648v0.003906c4.6875 4.6367 5.3984 11.957 1.6914 17.406zm-36.047 61.031c0-14.504-5.7578-28.41-16.016-38.664-10.254-10.258-24.16-16.016-38.664-16.016s-28.41 5.7578-38.664 16.016c-10.258 10.254-16.016 24.16-16.016 38.664s5.7578 28.41 16.016 38.664c10.254 10.258 24.16 16.016 38.664 16.016 14.5-0.011719 28.398-5.7773 38.652-16.027 10.25-10.254 16.016-24.152 16.027-38.652z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48" fill="#FFF"><path d="M7.05 40q-1.2 0-2.1-.925-.9-.925-.9-2.075V11q0-1.15.9-2.075Q5.85 8 7.05 8h14l3 3h17q1.15 0 2.075.925.925.925.925 2.075v23q0 1.15-.925 2.075Q42.2 40 41.05 40Zm0-29v26h34V14H22.8l-3-3H7.05Zm0 0v26Z"/></svg>

After

Width:  |  Height:  |  Size: 289 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 27 KiB

+590
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 202 KiB

+15 -8
View File
@@ -124,18 +124,23 @@ This script generates:
- Accurate description and usage examples
- Correct icon path (usually `../../../../.icons/your-icon.svg`)
- Proper tags that describe your module
3. **Create at least one `.tftest.hcl`** to test your module with `terraform test`
3. **Create tests for your module:**
- **Terraform tests**: Create a `*.tftest.hcl` file and test with `terraform test`
- **TypeScript tests**: Create `main.test.ts` file if your module runs scripts or has business logic that Terraform tests can't cover
4. **Add any scripts** or additional files your module needs
### 4. Test and Submit
```bash
# Test your module (from the module directory)
# Test your module
cd registry/[namespace]/modules/[module-name]
# Required: Test Terraform functionality
terraform init -upgrade
terraform test -verbose
# Or run all tests in the repo
./scripts/terraform_test_all.sh
# Optional: Test TypeScript files if you have main.test.ts
bun test main.test.ts
# Format code
bun run fmt
@@ -343,8 +348,8 @@ coder templates push test-[template-name] -d .
terraform init -upgrade
terraform test -verbose
# Test all modules
./scripts/terraform_test_all.sh
# Optional: If you have TypeScript tests
bun test main.test.ts
```
### 3. Maintain Backward Compatibility
@@ -393,7 +398,9 @@ Example: `https://github.com/coder/registry/compare/main...your-branch?template=
### Every Module Must Have
- `main.tf` - Terraform code
- One or more `.tftest.hcl` files - Working tests with `terraform test`
- **Tests**:
- `*.tftest.hcl` files with `terraform test` (to test terraform specific logic)
- `main.test.ts` file with `bun test` (to test business logic, i.e., `coder_script` to install a package.)
- `README.md` - Documentation with frontmatter
### Every Template Must Have
@@ -493,7 +500,7 @@ When reporting bugs, include:
2. **No tests** or broken tests
3. **Hardcoded values** instead of variables
4. **Breaking changes** without defaults
5. **Not running** formatting (`bun run fmt`) and tests (`terraform test`) before submitting
5. **Not running** formatting (`bun run fmt`) and tests (`terraform test`, and `bun test main.test.ts` if applicable) before submitting
## For Maintainers
+2 -2
View File
@@ -15,7 +15,7 @@ run "app_url_uses_port" {
}
assert {
condition = resource.coder_app.MODULE_NAME.url == "http://localhost:19999"
error_message = "Expected MODULE_NAME app URL to include configured port"
condition = resource.coder_app.module_name.url == "http://localhost:19999"
error_message = "Expected module-name app URL to include configured port"
}
}
+12 -12
View File
@@ -35,13 +35,13 @@ variable "agent_id" {
variable "log_path" {
type = string
description = "The path to log MODULE_NAME to."
default = "/tmp/MODULE_NAME.log"
description = "The path to the module log file."
default = "/tmp/module_name.log"
}
variable "port" {
type = number
description = "The port to run MODULE_NAME on."
description = "The port to run the application on."
default = 19999
}
@@ -59,9 +59,9 @@ variable "order" {
# Add other variables here
resource "coder_script" "MODULE_NAME" {
resource "coder_script" "module_name" {
agent_id = var.agent_id
display_name = "MODULE_NAME"
display_name = "Module Name"
icon = local.icon_url
script = templatefile("${path.module}/run.sh", {
LOG_PATH : var.log_path,
@@ -70,10 +70,10 @@ resource "coder_script" "MODULE_NAME" {
run_on_stop = false
}
resource "coder_app" "MODULE_NAME" {
resource "coder_app" "module_name" {
agent_id = var.agent_id
slug = "MODULE_NAME"
display_name = "MODULE_NAME"
slug = "module-name"
display_name = "Module Name"
url = "http://localhost:${var.port}"
icon = local.icon_url
subdomain = false
@@ -88,10 +88,10 @@ resource "coder_app" "MODULE_NAME" {
}
}
data "coder_parameter" "MODULE_NAME" {
type = "list(string)"
name = "MODULE_NAME"
display_name = "MODULE_NAME"
data "coder_parameter" "module_name" {
type = "string"
name = "module_name"
display_name = "Module Name"
icon = local.icon_url
mutable = var.mutable
default = local.options["Option 1"]["value"]
+2 -1
View File
@@ -4,7 +4,8 @@
"fmt": "bun x prettier --write . && terraform fmt -recursive -diff",
"fmt:ci": "bun x prettier --check . && terraform fmt -check -recursive -diff",
"terraform-validate": "./scripts/terraform_validate.sh",
"test": "./scripts/terraform_test_all.sh",
"tftest": "./scripts/terraform_test_all.sh",
"tstest": "./scripts/ts_test_auto.sh",
"update-version": "./update-version.sh"
},
"devDependencies": {
@@ -0,0 +1,163 @@
---
display_name: Archive
description: Create automated and user-invocable scripts that archive and extract selected files/directories with optional compression (gzip or zstd).
icon: ../../../../.icons/folder.svg
verified: false
tags: [backup, archive, tar, helper]
---
# Archive
This module installs small, robust scripts in your workspace to create and extract tar archives from a list of files and directories. It supports optional compression (gzip or zstd). The create command prints only the resulting archive path to stdout; operational logs go to stderr. An optional stop hook can also create an archive automatically when the workspace stops, and an optional start hook can wait for an archive on-disk and extract it on start.
```tf
module "archive" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/archive/coder"
version = "0.0.1"
agent_id = coder_agent.example.id
paths = ["./projects", "./code"]
}
```
## Features
- Installs two commands into the workspace `$PATH`: `coder-archive-create` and `coder-archive-extract`.
- Creates a single `.tar`, `.tar.gz`, or `.tar.zst` containing selected paths (depends on `tar`).
- Optional compression: `gzip`, `zstd` (depends on `gzip` or `zstd`).
- Stores defaults so commands can be run without arguments (supports overriding via CLI flags).
- Logs and status messages go to stderr, the create command prints only the final archive path to stdout.
- Optional:
- `create_on_stop` to create an archive automatically when the workspace stops.
- `extract_on_start` to wait for an archive to appear and extract it on start.
> [!WARNING]
> The `create_on_stop` feature uses the `coder_script` `run_on_stop` which may not work as expected on certain templates without additional provider configuration. The agent may be terminated before the script completes. See [coder/coder#6174](https://github.com/coder/coder/issues/6174) for provider-specific workarounds and [coder/coder#6175](https://github.com/coder/coder/issues/6175) for tracking a fix.
## Usage
Basic example:
```tf
module "archive" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/archive/coder"
version = "0.0.1"
agent_id = coder_agent.example.id
# Paths to include in the archive (files or directories).
directory = "~"
paths = [
"./projects",
"./code",
]
}
```
Customize compression and output:
```tf
module "archive" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/archive/coder"
version = "0.0.1"
agent_id = coder_agent.example.id
directory = "/"
paths = ["/etc", "/home"]
compression = "zstd" # "gzip" | "zstd" | "none"
output_dir = "/tmp/backup" # defaults to /tmp
archive_name = "my-backup" # base name (extension is inferred from compression)
}
```
Enable auto-archive on stop:
```tf
module "archive" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/archive/coder"
version = "0.0.1"
agent_id = coder_agent.example.id
# Creates /tmp/coder-archive.tar.gz of the users home directory (defaults).
create_on_stop = true
}
```
Extract on start:
```tf
module "archive" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/archive/coder"
version = "0.0.1"
agent_id = coder_agent.example.id
# Where to look for the archive file to extract:
output_dir = "/tmp"
archive_name = "my-archive"
compression = "gzip"
# Waits up to 5 minutes for /tmp/my-archive.tar.gz to be present, note that
# using a long timeout will delay every workspace start by this much until the
# archive is present.
extract_on_start = true
extract_wait_timeout_seconds = 300
}
```
## Command usage
The installer writes the following files:
- `$CODER_SCRIPT_DATA_DIR/archive-lib.sh`
- `$CODER_SCRIPT_BIN_DIR/coder-archive-create`
- `$CODER_SCRIPT_BIN_DIR/coder-archive-extract`
Create usage:
```console
coder-archive-create [OPTIONS] [PATHS...]
-c, --compression <gzip|zstd|none> Compression algorithm (default from module)
-C, --directory <DIRECTORY> Change to directory for archiving (default from module)
-f, --file <ARCHIVE> Output archive file (default from module)
-h, --help Show help
```
Extract usage:
```console
coder-archive-extract [OPTIONS]
-c, --compression <gzip|zstd|none> Compression algorithm (default from module)
-C, --directory <DIRECTORY> Extract into directory (default from module)
-f, --file <ARCHIVE> Archive file to extract (default from module)
-h, --help Show help
```
Examples:
- Use Terraform defaults:
```
coder-archive-create
```
- Override compression and output file at runtime:
```
coder-archive-create --compression zstd --file /tmp/backups/archive.tar.zst
```
- Add extra paths on the fly (in addition to the Terraform defaults):
```
coder-archive-create /etc/hosts
```
- Extract an archive into a directory:
```
coder-archive-extract --file /tmp/backups/archive.tar.gz --directory /tmp/restore
```
@@ -0,0 +1,33 @@
mock_provider "coder" {}
run "apply_defaults" {
command = apply
variables {
agent_id = "agent-123"
paths = ["~/project", "/etc/hosts"]
}
assert {
condition = output.archive_path == "/tmp/coder-archive.tar.gz"
error_message = "archive_path should be empty when archive_name is not set"
}
}
run "apply_with_name" {
command = apply
variables {
agent_id = "agent-123"
paths = ["/etc/hosts"]
archive_name = "nightly"
output_dir = "/tmp/backups"
compression = "zstd"
create_archive_on_stop = true
}
assert {
condition = output.archive_path == "/tmp/backups/nightly.tar.zst"
error_message = "archive_path should be computed from archive_name + output_dir + extension"
}
}
@@ -0,0 +1,348 @@
import { describe, expect, it, beforeAll } from "bun:test";
import {
execContainer,
findResourceInstance,
runContainer,
runTerraformApply,
runTerraformInit,
testRequiredVariables,
type TerraformState,
} from "~test";
const USE_XTRACE =
process.env.ARCHIVE_TEST_XTRACE === "1" || process.env.XTRACE === "1";
const IMAGE = "alpine";
const BIN_DIR = "/tmp/coder-script-data/bin";
const DATA_DIR = "/tmp/coder-script-data";
type ExecResult = {
exitCode: number;
stdout: string;
stderr: string;
};
const ensureRunOk = (label: string, res: ExecResult) => {
if (res.exitCode !== 0) {
console.error(
`[${label}] non-zero exit code: ${res.exitCode}\n--- stdout ---\n${res.stdout.trim()}\n--- stderr ---\n${res.stderr.trim()}\n--------------`,
);
}
expect(res.exitCode).toBe(0);
};
const sh = async (id: string, cmd: string): Promise<ExecResult> => {
const res = await execContainer(id, ["sh", "-c", cmd]);
return res;
};
const bashRun = async (id: string, cmd: string): Promise<ExecResult> => {
const injected = USE_XTRACE ? `/bin/bash -x ${cmd}` : cmd;
return sh(id, injected);
};
const prepareContainer = async (image = IMAGE) => {
const id = await runContainer(image);
// Prepare script dirs and deps.
ensureRunOk(
"mkdirs",
await sh(id, `mkdir -p ${BIN_DIR} ${DATA_DIR} /tmp/backup`),
);
// Install tools used by tests.
ensureRunOk(
"apk add",
await sh(id, "apk add --no-cache bash tar gzip zstd coreutils"),
);
return id;
};
const installArchive = async (
state: TerraformState,
opts?: { env?: string[] },
) => {
const instance = findResourceInstance(state, "coder_script");
const id = await prepareContainer();
// Run installer script with correct env for CODER_SCRIPT paths.
const args = ["bash"];
if (USE_XTRACE) args.push("-x");
args.push("-c", instance.script);
const resp = await execContainer(id, args, [
"--env",
`CODER_SCRIPT_BIN_DIR=${BIN_DIR}`,
"--env",
`CODER_SCRIPT_DATA_DIR=${DATA_DIR}`,
...(opts?.env ?? []),
]);
return {
id,
install: {
exitCode: resp.exitCode,
stdout: resp.stdout.trim(),
stderr: resp.stderr.trim(),
},
};
};
const fileExists = async (id: string, path: string) => {
const res = await sh(id, `test -f ${path} && echo yes || echo no`);
return res.stdout.trim() === "yes";
};
const isExecutable = async (id: string, path: string) => {
const res = await sh(id, `test -x ${path} && echo yes || echo no`);
return res.stdout.trim() === "yes";
};
const listTar = async (id: string, path: string) => {
// Try to autodetect compression flags from extension.
let cmd = "";
if (path.endsWith(".tar.gz")) {
cmd = `tar -tzf ${path}`;
} else if (path.endsWith(".tar.zst")) {
// validate with zstd and ask tar to list via --zstd.
cmd = `zstd -t -q ${path} && tar --zstd -tf ${path}`;
} else {
cmd = `tar -tf ${path}`;
}
return sh(id, cmd);
};
describe("archive", () => {
beforeAll(async () => {
await runTerraformInit(import.meta.dir);
});
// Ensure required variables are enforced.
testRequiredVariables(import.meta.dir, {
agent_id: "agent-123",
});
it("installs wrapper scripts to BIN_DIR and library to DATA_DIR", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "agent-123",
});
// The Terraform output should reflect defaults from main.tf.
expect(state.outputs.archive_path.value).toEqual(
"/tmp/coder-archive.tar.gz",
);
const { id, install } = await installArchive(state);
ensureRunOk("install", install);
expect(install.stdout).toContain(
`Installed archive library to: ${DATA_DIR}/archive-lib.sh`,
);
expect(install.stdout).toContain(
`Installed create script to: ${BIN_DIR}/coder-archive-create`,
);
expect(install.stdout).toContain(
`Installed extract script to: ${BIN_DIR}/coder-archive-extract`,
);
expect(await isExecutable(id, `${BIN_DIR}/coder-archive-create`)).toBe(
true,
);
expect(await isExecutable(id, `${BIN_DIR}/coder-archive-extract`)).toBe(
true,
);
});
it("uses sane defaults: creates gzip archive at the default path and logs to stderr", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "agent-123",
// Keep defaults: compression=gzip, output_dir=/tmp, archive_name=coder-archive.
});
const { id } = await installArchive(state);
const createTestdata = await bashRun(
id,
`mkdir ~/gzip; touch ~/gzip/defaults.txt`,
);
ensureRunOk("create testdata", createTestdata);
const run = await bashRun(id, `${BIN_DIR}/coder-archive-create`);
ensureRunOk("archive-create default run", run);
// Only the archive path should print to stdout.
expect(run.stdout.trim()).toEqual("/tmp/coder-archive.tar.gz");
expect(await fileExists(id, "/tmp/coder-archive.tar.gz")).toBe(true);
// Some useful diagnostics should be on stderr.
expect(run.stderr).toContain("Creating archive:");
expect(run.stderr).toContain("Compression: gzip");
const list = await listTar(id, "/tmp/coder-archive.tar.gz");
ensureRunOk("list default archive", list);
expect(list.stdout).toContain("gzip/defaults.txt");
}, 20000);
it("creates a gzip archive with explicit -f and includes extra CLI paths", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "agent-123",
// Provide a simple default path so we can assert contents.
paths: `["~/gzip"]`,
compression: "gzip",
});
const { id } = await installArchive(state);
const createTestdata = await bashRun(
id,
`mkdir ~/gzip; touch ~/gzip/test.txt; touch ~/gziptest.txt`,
);
ensureRunOk("create testdata", createTestdata);
const out = "/tmp/backup/test-archive.tar.gz";
const run = await bashRun(
id,
`${BIN_DIR}/coder-archive-create -f ${out} ~/gziptest.txt`,
);
ensureRunOk("archive-create gzip explicit -f", run);
expect(run.stdout.trim()).toEqual(out);
expect(await fileExists(id, out)).toBe(true);
const list = await sh(id, `tar -tzf ${out}`);
ensureRunOk("tar -tzf contents (gzip)", list);
expect(list.stdout).toContain("gzip/test.txt");
expect(list.stdout).toContain("gziptest.txt");
}, 20000);
it("creates a zstd-compressed archive when requested via CLI override", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "agent-123",
paths: `["/etc/hostname"]`,
// Module default is gzip, override at runtime to zstd.
});
const { id } = await installArchive(state);
const out = "/tmp/backup/zstd-archive.tar.zst";
const run = await bashRun(
id,
`${BIN_DIR}/coder-archive-create --compression zstd -f ${out}`,
);
ensureRunOk("archive-create zstd", run);
expect(run.stdout.trim()).toEqual(out);
// Check integrity via zstd and that tar can list it.
ensureRunOk("zstd -t", await sh(id, `test -f ${out} && zstd -t -q ${out}`));
ensureRunOk("tar --zstd -tf", await sh(id, `tar --zstd -tf ${out}`));
}, 30000);
it("creates an uncompressed tar when compression=none", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "agent-123",
// Keep module defaults but override at runtime.
});
const { id } = await installArchive(state);
const out = "/tmp/backup/raw-archive.tar";
const run = await bashRun(
id,
`${BIN_DIR}/coder-archive-create --compression none -f ${out}`,
);
ensureRunOk("archive-create none", run);
expect(run.stdout.trim()).toEqual(out);
ensureRunOk("tar -tf (none)", await sh(id, `tar -tf ${out} >/dev/null`));
}, 20000);
it("applies exclude patterns from Terraform", async () => {
// Include a file, but also exclude it via Terraform defaults to ensure
// exclusion flows through.
const state = await runTerraformApply(import.meta.dir, {
agent_id: "agent-123",
paths: `["/etc/hostname"]`,
exclude_patterns: `["/etc/hostname"]`,
});
const { id } = await installArchive(state);
const out = "/tmp/backup/excluded.tar.gz";
const run = await bashRun(id, `${BIN_DIR}/coder-archive-create -f ${out}`);
ensureRunOk("archive-create with exclude_patterns", run);
const list = await sh(id, `tar -tzf ${out}`);
ensureRunOk("tar -tzf contents (exclude)", list);
expect(list.stdout).not.toContain("etc/hostname"); // Excluded by Terraform default.
}, 20000);
it("adds a run_on_stop script when enabled", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "agent-123",
create_on_stop: true,
});
const coderScripts = state.resources.filter(
(r) => r.type === "coder_script",
);
// Installer (run_on_start) + run_on_stop.
expect(coderScripts.length).toBe(2);
});
it("extracts a previously created archive into a target directory", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "agent-123",
paths: `["/etc/hostname"]`,
compression: "gzip",
});
const { id } = await installArchive(state);
// Create archive.
const out = "/tmp/backup/extract-test.tar.gz";
const created = await bashRun(
id,
`${BIN_DIR}/coder-archive-create -f ${out} /etc/hosts`,
);
ensureRunOk("create for extract", created);
// Extract archive.
const extractDir = "/tmp/extract";
const extract = await bashRun(
id,
`${BIN_DIR}/coder-archive-extract -f ${out} -C ${extractDir}`,
);
ensureRunOk("archive-extract", extract);
// Verify a known file exists after extraction.
const exists = await sh(
id,
`test -f ${extractDir}/etc/hosts && echo ok || echo no`,
);
expect(exists.stdout.trim()).toEqual("ok");
}, 20000);
it("honors Terraform defaults without CLI args (compression, name, output_dir)", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "agent-123",
compression: "zstd",
archive_name: "my-default",
output_dir: "/tmp/defout",
});
const { id } = await installArchive(state);
const run = await bashRun(id, `${BIN_DIR}/coder-archive-create`);
ensureRunOk("archive-create terraform defaults", run);
expect(run.stdout.trim()).toEqual("/tmp/defout/my-default.tar.zst");
expect(run.stderr).toContain("Creating archive:");
expect(run.stderr).toContain("Compression: zstd");
ensureRunOk(
"zstd -t",
await sh(id, "zstd -t -q /tmp/defout/my-default.tar.zst"),
);
ensureRunOk(
"tar --zstd -tf",
await sh(id, "tar --zstd -tf /tmp/defout/my-default.tar.zst"),
);
}, 30000);
});
+134
View File
@@ -0,0 +1,134 @@
terraform {
required_version = ">= 1.0"
required_providers {
coder = {
source = "coder/coder"
version = ">= 0.12"
}
}
}
variable "agent_id" {
description = "The ID of a Coder agent."
type = string
}
variable "paths" {
description = "List of files/directories to include in the archive. Defaults to the current directory."
type = list(string)
default = ["."]
}
variable "exclude_patterns" {
description = "Exclude patterns for the archive."
type = list(string)
default = []
}
variable "compression" {
description = "Compression algorithm for the archive. Supported: gzip, zstd, none."
type = string
default = "gzip"
validation {
condition = contains(["gzip", "zstd", "none"], var.compression)
error_message = "compression must be one of: gzip, zstd, none."
}
}
variable "archive_name" {
description = "Optional archive base name without extension. If empty, defaults to \"coder-archive\"."
type = string
default = "coder-archive"
}
variable "output_dir" {
description = "Optional output directory where the archive will be written. Defaults to \"/tmp\"."
type = string
default = "/tmp"
}
variable "directory" {
description = "Change current directory to this path before creating or extracting the archive. Defaults to the user's home directory."
type = string
default = "~"
}
variable "create_on_stop" {
description = "If true, also create a run_on_stop script that creates the archive automatically on workspace stop."
type = bool
default = false
}
variable "extract_on_start" {
description = "If true, the installer will wait for an archive and extract it on start."
type = bool
default = false
}
variable "extract_wait_timeout_seconds" {
description = "Timeout (seconds) to wait for an archive when extract_on_start is true."
type = number
default = 5
}
# Provide a stable script filename and sensible defaults.
locals {
extension = var.compression == "gzip" ? ".tar.gz" : var.compression == "zstd" ? ".tar.zst" : ".tar"
# Ensure ~ is expanded because it cannot be expanded inside quotes in a
# templated shell script.
paths = [for v in var.paths : replace(v, "/^~(\\/|$)/", "$$HOME$1")]
exclude_patterns = [for v in var.exclude_patterns : replace(v, "/^~(\\/|$)/", "$$HOME$1")]
directory = replace(var.directory, "/^~(\\/|$)/", "$$HOME$1")
output_dir = replace(var.output_dir, "/^~(\\/|$)/", "$$HOME$1")
archive_path = "${local.output_dir}/${var.archive_name}${local.extension}"
}
output "archive_path" {
description = "Full path to the archive file that will be created, extracted, or both."
value = local.archive_path
}
# This script installs the user-facing archive script into $CODER_SCRIPT_BIN_DIR.
# The installed script can be run manually by the user to create an archive.
resource "coder_script" "archive_start_script" {
agent_id = var.agent_id
display_name = "Archive"
icon = "/icon/folder.svg"
run_on_start = true
start_blocks_login = var.extract_on_start
# Render the user-facing archive script with Terraform defaults, then write it to $CODER_SCRIPT_BIN_DIR
script = templatefile("${path.module}/run.sh", {
TF_LIB_B64 = base64encode(file("${path.module}/scripts/archive-lib.sh")),
TF_PATHS = join(" ", formatlist("%q", local.paths)),
TF_EXCLUDE_PATTERNS = join(" ", formatlist("%q", local.exclude_patterns)),
TF_COMPRESSION = var.compression,
TF_ARCHIVE_PATH = local.archive_path,
TF_DIRECTORY = local.directory,
TF_EXTRACT_ON_START = var.extract_on_start,
TF_EXTRACT_WAIT_TIMEOUT = var.extract_wait_timeout_seconds,
})
}
# Optionally, also register a run_on_stop script that creates the archive automatically
# when the workspace stops. It simply invokes the installed archive script.
resource "coder_script" "archive_stop_script" {
count = var.create_on_stop ? 1 : 0
agent_id = var.agent_id
display_name = "Archive"
icon = "/icon/folder.svg"
run_on_stop = true
start_blocks_login = false
# Call the installed script. It will log to stderr and print the archive path to stdout.
# We redirect stdout to stderr to avoid surfacing the path in system logs if undesired.
# Remove the redirection if you want the path to appear in stdout on stop as well.
script = <<-EOT
#!/usr/bin/env bash
set -euo pipefail
"$CODER_SCRIPT_BIN_DIR/coder-archive-create"
EOT
}
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
set -euo pipefail
LIB_B64="${TF_LIB_B64}"
EXTRACT_ON_START="${TF_EXTRACT_ON_START}"
EXTRACT_WAIT_TIMEOUT="${TF_EXTRACT_WAIT_TIMEOUT}"
# Set script defaults from Terraform.
DEFAULT_PATHS=(${TF_PATHS})
DEFAULT_EXCLUDE_PATTERNS=(${TF_EXCLUDE_PATTERNS})
DEFAULT_COMPRESSION="${TF_COMPRESSION}"
DEFAULT_ARCHIVE_PATH="${TF_ARCHIVE_PATH}"
DEFAULT_DIRECTORY="${TF_DIRECTORY}"
# 1) Decode the library into $CODER_SCRIPT_DATA_DIR/archive-lib.sh (static, sourceable).
LIB_PATH="$CODER_SCRIPT_DATA_DIR/archive-lib.sh"
lib_tmp="$(mktemp -t coder-module-archive.XXXXXX))"
trap 'rm -f "$lib_tmp" 2>/dev/null || true' EXIT
# Decode the base64 content safely.
if ! printf '%s' "$LIB_B64" | base64 -d > "$lib_tmp"; then
echo "ERROR: Failed to decode archive library from base64." >&2
exit 1
fi
chmod 0644 "$lib_tmp"
mv "$lib_tmp" "$LIB_PATH"
# 2) Generate the wrapper scripts (create and extract).
create_wrapper() {
tmp="$(mktemp -t coder-module-archive.XXXXXX)"
trap 'rm -f "$tmp" 2>/dev/null || true' EXIT
cat > "$tmp" << EOF
#!/usr/bin/env bash
set -euo pipefail
. "$LIB_PATH"
# Set defaults from Terraform (through installer).
$(
declare -p \
DEFAULT_PATHS \
DEFAULT_EXCLUDE_PATTERNS \
DEFAULT_COMPRESSION \
DEFAULT_ARCHIVE_PATH \
DEFAULT_DIRECTORY
)
$1 "\$@"
EOF
chmod 0755 "$tmp"
mv "$tmp" "$2"
}
CREATE_WRAPPER_PATH="$CODER_SCRIPT_BIN_DIR/coder-archive-create"
EXTRACT_WRAPPER_PATH="$CODER_SCRIPT_BIN_DIR/coder-archive-extract"
create_wrapper archive_create "$CREATE_WRAPPER_PATH"
create_wrapper archive_extract "$EXTRACT_WRAPPER_PATH"
echo "Installed archive library to: $LIB_PATH"
echo "Installed create script to: $CREATE_WRAPPER_PATH"
echo "Installed extract script to: $EXTRACT_WRAPPER_PATH"
# 3) Optionally wait for and extract an archive on start.
if [[ $EXTRACT_ON_START = true ]]; then
. "$LIB_PATH"
archive_wait_and_extract "$EXTRACT_WAIT_TIMEOUT" quiet || {
exit_code=$?
if [[ $exit_code -eq 2 ]]; then
echo "WARNING: Archive not found in backup path (this is expected with new workspaces)."
else
exit $exit_code
fi
}
fi
@@ -0,0 +1,279 @@
#!/usr/bin/env bash
set -euo pipefail
log() {
printf '%s\n' "$@" >&2
}
warn() {
printf 'WARNING: %s\n' "$1" >&2
}
error() {
printf 'ERROR: %s\n' "$1" >&2
exit 1
}
load_defaults() {
DEFAULT_PATHS=("${DEFAULT_PATHS[@]:-.}")
DEFAULT_EXCLUDE_PATTERNS=("${DEFAULT_EXCLUDE_PATTERNS[@]:-}")
DEFAULT_COMPRESSION="${DEFAULT_COMPRESSION:-gzip}"
DEFAULT_ARCHIVE_PATH="${DEFAULT_ARCHIVE_PATH:-/tmp/coder-archive.tar.gz}"
DEFAULT_DIRECTORY="${DEFAULT_DIRECTORY:-$HOME}"
}
ensure_tools() {
command -v tar > /dev/null 2>&1 || error "tar is required"
case "$1" in
gzip)
command -v gzip > /dev/null 2>&1 || error "gzip is required for gzip compression"
;;
zstd)
command -v zstd > /dev/null 2>&1 || error "zstd is required for zstd compression"
;;
none) ;;
*)
error "Unsupported compression algorithm: $1"
;;
esac
}
usage_archive_create() {
load_defaults
cat >&2 << USAGE
Usage: coder-archive-create [OPTIONS] [[PATHS] ...]
Options:
-c, --compression <gzip|zstd|none> Compression algorithm (default "${DEFAULT_COMPRESSION}")
-C, --directory <DIRECTORY> Change to directory (default "${DEFAULT_DIRECTORY}")
-f, --file <ARCHIVE> Output archive file (default "${DEFAULT_ARCHIVE_PATH}")
-h, --help Show this help
USAGE
}
archive_create() {
load_defaults
local compression="${DEFAULT_COMPRESSION}"
local directory="${DEFAULT_DIRECTORY}"
local file="${DEFAULT_ARCHIVE_PATH}"
local paths=("${DEFAULT_PATHS[@]}")
while [[ $# -gt 0 ]]; do
case "$1" in
-c | --compression)
if [[ $# -lt 2 ]]; then
usage_archive_create
error "Missing value for $1"
fi
compression="$2"
shift 2
;;
-C | --directory)
if [[ $# -lt 2 ]]; then
usage_archive_create
error "Missing value for $1"
fi
directory="$2"
shift 2
;;
-f | --file)
if [[ $# -lt 2 ]]; then
usage_archive_create
error "Missing value for $1"
fi
file="$2"
shift 2
;;
-h | --help)
usage_archive_create
exit 0
;;
--)
shift
while [[ $# -gt 0 ]]; do
paths+=("$1")
shift
done
;;
-*)
usage_archive_create
error "Unknown option: $1"
;;
*)
paths+=("$1")
shift
;;
esac
done
ensure_tools "$compression"
local -a tar_opts=(-c -f "$file" -C "$directory")
case "$compression" in
gzip)
tar_opts+=(-z)
;;
zstd)
tar_opts+=(--zstd)
;;
none) ;;
*)
error "Unsupported compression algorithm: $compression"
;;
esac
for path in "${DEFAULT_EXCLUDE_PATTERNS[@]}"; do
if [[ -n $path ]]; then
tar_opts+=(--exclude "$path")
fi
done
# Ensure destination directory exists.
dest="$(dirname "$file")"
mkdir -p "$dest" 2> /dev/null || error "Failed to create output dir: $dest"
log "Creating archive:"
log " Compression: $compression"
log " Directory: $directory"
log " Archive: $file"
log " Paths: ${paths[*]}"
log " Exclude: ${DEFAULT_EXCLUDE_PATTERNS[*]}"
umask 077
tar "${tar_opts[@]}" "${paths[@]}"
printf '%s\n' "$file"
}
usage_archive_extract() {
load_defaults
cat >&2 << USAGE
Usage: coder-archive-extract [OPTIONS]
Options:
-c, --compression <gzip|zstd|none> Compression algorithm (default "${DEFAULT_COMPRESSION}")
-C, --directory <DIRECTORY> Change to directory (default "${DEFAULT_DIRECTORY}")
-f, --file <ARCHIVE> Output archive file (default "${DEFAULT_ARCHIVE_PATH}")
-h, --help Show this help
USAGE
}
archive_extract() {
load_defaults
local compression="${DEFAULT_COMPRESSION}"
local directory="${DEFAULT_DIRECTORY}"
local file="${DEFAULT_ARCHIVE_PATH}"
while [[ $# -gt 0 ]]; do
case "$1" in
-c | --compression)
if [[ $# -lt 2 ]]; then
usage_archive_extract
error "Missing value for $1"
fi
compression="$2"
shift 2
;;
-C | --directory)
if [[ $# -lt 2 ]]; then
usage_archive_extract
error "Missing value for $1"
fi
directory="$2"
shift 2
;;
-f | --file)
if [[ $# -lt 2 ]]; then
usage_archive_extract
error "Missing value for $1"
fi
file="$2"
shift 2
;;
-h | --help)
usage_archive_extract
exit 0
;;
--)
shift
while [[ $# -gt 0 ]]; do
shift
done
;;
-*)
usage_archive_extract
error "Unknown option: $1"
;;
*)
shift
;;
esac
done
ensure_tools "$compression"
local -a tar_opts=(-x -f "$file" -C "$directory")
case "$compression" in
gzip)
tar_opts+=(-z)
;;
zstd)
tar_opts+=(--zstd)
;;
none) ;;
*)
error "Unsupported compression algorithm: $compression"
;;
esac
for path in "${DEFAULT_EXCLUDE_PATTERNS[@]}"; do
if [[ -n $path ]]; then
tar_opts+=(--exclude "$path")
fi
done
# Ensure destination directory exists.
mkdir -p "$directory" || error "Failed to create directory: $directory"
log "Extracting archive:"
log " Compression: $compression"
log " Directory: $directory"
log " Archive: $file"
log " Exclude: ${DEFAULT_EXCLUDE_PATTERNS[*]}"
umask 077
tar "${tar_opts[@]}" "${paths[@]}"
printf 'Extracted %s into %s\n' "$file" "$directory"
}
archive_wait_and_extract() {
load_defaults
local timeout="${1:-300}"
local quiet="${2:-}"
local file="${DEFAULT_ARCHIVE_PATH}"
local start now
start=$(date +%s)
while true; do
if [[ -f "$file" ]]; then
archive_extract -f "$file"
return 0
fi
if ((timeout <= 0)); then
break
fi
now=$(date +%s)
if ((now - start >= timeout)); then
break
fi
sleep 5
done
if [[ -z $quiet ]]; then
printf 'ERROR: Timed out waiting for archive: %s\n' "$file" >&2
fi
return 2
}
+3 -3
View File
@@ -13,7 +13,7 @@ Run Auggie CLI in your workspace to access Augment's AI coding assistant with ad
```tf
module "auggie" {
source = "registry.coder.com/coder-labs/auggie/coder"
version = "0.2.0"
version = "0.2.1"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
}
@@ -47,7 +47,7 @@ module "coder-login" {
module "auggie" {
source = "registry.coder.com/coder-labs/auggie/coder"
version = "0.2.0"
version = "0.2.1"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
@@ -103,7 +103,7 @@ EOF
```tf
module "auggie" {
source = "registry.coder.com/coder-labs/auggie/coder"
version = "0.2.0"
version = "0.2.1"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
@@ -174,6 +174,7 @@ locals {
install_script = file("${path.module}/scripts/install.sh")
start_script = file("${path.module}/scripts/start.sh")
module_dir_name = ".auggie-module"
folder = trimsuffix(var.folder, "/")
}
module "agentapi" {
@@ -181,6 +182,7 @@ module "agentapi" {
version = "1.2.0"
agent_id = var.agent_id
folder = local.folder
web_app_slug = local.app_slug
web_app_order = var.order
web_app_group = var.group
+10 -9
View File
@@ -13,10 +13,10 @@ Run Codex CLI in your workspace to access OpenAI's models through the Codex inte
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "2.1.0"
version = "3.0.0"
agent_id = coder_agent.example.id
openai_api_key = var.openai_api_key
folder = "/home/coder/project"
workdir = "/home/coder/project"
}
```
@@ -33,10 +33,11 @@ module "codex" {
module "codex" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/codex/coder"
version = "2.1.0"
version = "3.0.0"
agent_id = coder_agent.example.id
openai_api_key = "..."
folder = "/home/coder/project"
workdir = "/home/coder/project"
report_tasks = false
}
```
@@ -60,11 +61,11 @@ module "coder-login" {
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "2.1.0"
version = "3.0.0"
agent_id = coder_agent.example.id
openai_api_key = "..."
ai_prompt = data.coder_parameter.ai_prompt.value
folder = "/home/coder/project"
workdir = "/home/coder/project"
# Custom configuration for full auto mode
base_config_toml = <<-EOT
@@ -75,7 +76,7 @@ module "codex" {
```
> [!WARNING]
> This module configures Codex with a `workspace-write` sandbox that allows AI tasks to read/write files in the specified folder. While the sandbox provides security boundaries, Codex can still modify files within the workspace. Use this module _only_ in trusted environments and be aware of the security implications.
> This module configures Codex with a `workspace-write` sandbox that allows AI tasks to read/write files in the specified workdir. While the sandbox provides security boundaries, Codex can still modify files within the workspace. Use this module _only_ in trusted environments and be aware of the security implications.
## How it Works
@@ -106,7 +107,7 @@ For custom Codex configuration, use `base_config_toml` and/or `additional_mcp_se
```tf
module "codex" {
source = "registry.coder.com/coder-labs/codex/coder"
version = "2.1.0"
version = "3.0.0"
# ... other variables ...
# Override default configuration
@@ -137,7 +138,7 @@ module "codex" {
> [!IMPORTANT]
> To use tasks with Codex CLI, ensure you have the `openai_api_key` variable set, and **you create a `coder_parameter` named `"AI Prompt"` and pass its value to the codex module's `ai_prompt` variable**. [Tasks Template Example](https://registry.coder.com/templates/coder-labs/tasks-docker).
> The module automatically configures Codex with your API key and model preferences.
> folder is a required variable for the module to function correctly.
> workdir is a required variable for the module to function correctly.
## References
@@ -47,7 +47,7 @@ const setup = async (props?: SetupProps): Promise<{ id: string }> => {
install_codex: props?.skipCodexMock ? "true" : "false",
install_agentapi: props?.skipAgentAPIMock ? "true" : "false",
codex_model: "gpt-4-turbo",
folder: "/home/coder",
workdir: "/home/coder",
...props?.moduleVariables,
},
registerCleanup,
@@ -166,12 +166,12 @@ describe("codex", async () => {
expect(postInstallLog).toContain("post-install-script");
});
test("folder-variable", async () => {
const folder = "/tmp/codex-test-folder";
test("workdir-variable", async () => {
const workdir = "/tmp/codex-test-workdir";
const { id } = await setup({
skipCodexMock: false,
moduleVariables: {
folder,
workdir,
},
});
await execModuleScript(id);
@@ -179,7 +179,7 @@ describe("codex", async () => {
id,
"/home/coder/.codex-module/install.log",
);
expect(resp).toContain(folder);
expect(resp).toContain(workdir);
});
test("additional-mcp-servers", async () => {
+43 -6
View File
@@ -36,11 +36,41 @@ variable "icon" {
default = "/icon/openai.svg"
}
variable "folder" {
variable "workdir" {
type = string
description = "The folder to run Codex in."
}
variable "report_tasks" {
type = bool
description = "Whether to enable task reporting to Coder UI via AgentAPI"
default = true
}
variable "subdomain" {
type = bool
description = "Whether to use a subdomain for AgentAPI."
default = false
}
variable "cli_app" {
type = bool
description = "Whether to create a CLI app for Codex"
default = false
}
variable "web_app_display_name" {
type = string
description = "Display name for the web app"
default = "Codex"
}
variable "cli_app_display_name" {
type = string
description = "Display name for the CLI app"
default = "Codex CLI"
}
variable "install_codex" {
type = bool
description = "Whether to install Codex."
@@ -120,6 +150,7 @@ resource "coder_env" "openai_api_key" {
}
locals {
workdir = trimsuffix(var.workdir, "/")
app_slug = "codex"
install_script = file("${path.module}/scripts/install.sh")
start_script = file("${path.module}/scripts/start.sh")
@@ -131,15 +162,18 @@ module "agentapi" {
version = "1.2.0"
agent_id = var.agent_id
folder = local.workdir
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 = "Codex"
cli_app_slug = "${local.app_slug}-cli"
cli_app_display_name = "Codex CLI"
web_app_display_name = var.web_app_display_name
cli_app = var.cli_app
cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null
cli_app_display_name = var.cli_app ? var.cli_app_display_name : null
module_dir_name = local.module_dir_name
install_agentapi = var.install_agentapi
agentapi_subdomain = var.subdomain
agentapi_version = var.agentapi_version
pre_install_script = var.pre_install_script
post_install_script = var.post_install_script
@@ -151,8 +185,9 @@ module "agentapi" {
echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
chmod +x /tmp/start.sh
ARG_OPENAI_API_KEY='${var.openai_api_key}' \
ARG_REPORT_TASKS='${var.report_tasks}' \
ARG_CODEX_MODEL='${var.codex_model}' \
ARG_CODEX_START_DIRECTORY='${var.folder}' \
ARG_CODEX_START_DIRECTORY='${var.workdir}' \
ARG_CODEX_TASK_PROMPT='${base64encode(var.ai_prompt)}' \
/tmp/start.sh
EOT
@@ -164,12 +199,14 @@ module "agentapi" {
echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh
chmod +x /tmp/install.sh
ARG_OPENAI_API_KEY='${var.openai_api_key}' \
ARG_REPORT_TASKS='${var.report_tasks}' \
ARG_INSTALL='${var.install_codex}' \
ARG_CODEX_VERSION='${var.codex_version}' \
ARG_BASE_CONFIG_TOML='${base64encode(var.base_config_toml)}' \
ARG_ADDITIONAL_MCP_SERVERS='${base64encode(var.additional_mcp_servers)}' \
ARG_CODER_MCP_APP_STATUS_SLUG='${local.app_slug}' \
ARG_CODEX_START_DIRECTORY='${var.folder}' \
ARG_CODEX_START_DIRECTORY='${var.workdir}' \
ARG_CODEX_INSTRUCTION_PROMPT='${base64encode(var.codex_system_prompt)}' \
/tmp/install.sh
EOT
@@ -22,6 +22,8 @@ printf "Start Directory: %s\n" "$ARG_CODEX_START_DIRECTORY"
printf "Has Base Config: %s\n" "$([ -n "$ARG_BASE_CONFIG_TOML" ] && echo "Yes" || echo "No")"
printf "Has Additional MCP: %s\n" "$([ -n "$ARG_ADDITIONAL_MCP_SERVERS" ] && echo "Yes" || echo "No")"
printf "Has System Prompt: %s\n" "$([ -n "$ARG_CODEX_INSTRUCTION_PROMPT" ] && echo "Yes" || echo "No")"
printf "OpenAI API Key: %s\n" "$([ -n "$ARG_OPENAI_API_KEY" ] && echo "Provided" || echo "Not provided")"
printf "Report Tasks: %s\n" "$ARG_REPORT_TASKS"
echo "======================================"
set +o nounset
@@ -100,13 +102,20 @@ EOF
append_mcp_servers_section() {
local config_path="$1"
if [ "${ARG_REPORT_TASKS}" == "false" ]; then
ARG_CODER_MCP_APP_STATUS_SLUG=""
CODER_MCP_AI_AGENTAPI_URL=""
else
CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284"
fi
cat << EOF >> "$config_path"
# MCP Servers Configuration
[mcp_servers.Coder]
command = "coder"
args = ["exp", "mcp", "server"]
env = { "CODER_MCP_APP_STATUS_SLUG" = "${ARG_CODER_MCP_APP_STATUS_SLUG}", "CODER_MCP_AI_AGENTAPI_URL" = "http://localhost:3284", "CODER_AGENT_URL" = "${CODER_AGENT_URL}", "CODER_AGENT_TOKEN" = "${CODER_AGENT_TOKEN}" }
env = { "CODER_MCP_APP_STATUS_SLUG" = "${ARG_CODER_MCP_APP_STATUS_SLUG}", "CODER_MCP_AI_AGENTAPI_URL" = "${CODER_MCP_AI_AGENTAPI_URL}" , "CODER_AGENT_URL" = "${CODER_AGENT_URL}", "CODER_AGENT_TOKEN" = "${CODER_AGENT_TOKEN}" }
description = "Report ALL tasks and statuses (in progress, done, failed) you are working on."
type = "stdio"
@@ -159,7 +168,21 @@ function add_instruction_prompt_if_exists() {
fi
}
function add_auth_json() {
AUTH_JSON_PATH="$HOME/.codex/auth.json"
mkdir -p "$(dirname "$AUTH_JSON_PATH")"
AUTH_JSON=$(
cat << EOF
{
"OPENAI_API_KEY": "${ARG_OPENAI_API_KEY}"
}
EOF
)
echo "$AUTH_JSON" > "$AUTH_JSON_PATH"
}
install_codex
codex --version
populate_config_toml
add_instruction_prompt_if_exists
add_auth_json
@@ -22,6 +22,7 @@ printf "OpenAI API Key: %s\n" "$([ -n "$ARG_OPENAI_API_KEY" ] && echo "Provided"
printf "Codex Model: %s\n" "${ARG_CODEX_MODEL:-"Default"}"
printf "Start Directory: %s\n" "$ARG_CODEX_START_DIRECTORY"
printf "Has Task Prompt: %s\n" "$([ -n "$ARG_CODEX_TASK_PROMPT" ] && echo "Yes" || echo "No")"
printf "Report Tasks: %s\n" "$ARG_REPORT_TASKS"
echo "======================================"
set +o nounset
CODEX_ARGS=()
@@ -57,7 +58,11 @@ fi
if [ -n "$ARG_CODEX_TASK_PROMPT" ]; then
printf "Running the task prompt %s\n" "$ARG_CODEX_TASK_PROMPT"
PROMPT="Complete the task at hand in one go. Every step of the way, report your progress using coder_report_task tool with proper summary and statuses. Your task at hand: $ARG_CODEX_TASK_PROMPT"
if [ "${ARG_REPORT_TASKS}" == "true" ]; then
PROMPT="Complete the task at hand in one go. Every step of the way, report your progress using coder_report_task tool with proper summary and statuses. Your task at hand: $ARG_CODEX_TASK_PROMPT"
else
PROMPT="Your task at hand: $ARG_CODEX_TASK_PROMPT"
fi
CODEX_ARGS+=("$PROMPT")
else
printf "No task prompt given.\n"
@@ -13,7 +13,7 @@ Run the Cursor Agent CLI in your workspace for interactive coding assistance and
```tf
module "cursor_cli" {
source = "registry.coder.com/coder-labs/cursor-cli/coder"
version = "0.2.0"
version = "0.2.1"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
}
@@ -42,7 +42,7 @@ module "coder-login" {
module "cursor_cli" {
source = "registry.coder.com/coder-labs/cursor-cli/coder"
version = "0.2.0"
version = "0.2.1"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
@@ -113,6 +113,7 @@ locals {
install_script = file("${path.module}/scripts/install.sh")
start_script = file("${path.module}/scripts/start.sh")
module_dir_name = ".cursor-cli-module"
folder = trimsuffix(var.folder, "/")
}
# Expose status slug and API key to the agent environment
@@ -134,6 +135,7 @@ module "agentapi" {
version = "1.2.0"
agent_id = var.agent_id
folder = local.folder
web_app_slug = local.app_slug
web_app_order = var.order
web_app_group = var.group
+4 -4
View File
@@ -13,7 +13,7 @@ Run [Gemini CLI](https://github.com/google-gemini/gemini-cli) in your workspace
```tf
module "gemini" {
source = "registry.coder.com/coder-labs/gemini/coder"
version = "2.1.0"
version = "2.1.1"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
}
@@ -46,7 +46,7 @@ variable "gemini_api_key" {
module "gemini" {
source = "registry.coder.com/coder-labs/gemini/coder"
version = "2.1.0"
version = "2.1.1"
agent_id = coder_agent.example.id
gemini_api_key = var.gemini_api_key
folder = "/home/coder/project"
@@ -94,7 +94,7 @@ data "coder_parameter" "ai_prompt" {
module "gemini" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/gemini/coder"
version = "2.1.0"
version = "2.1.1"
agent_id = coder_agent.example.id
gemini_api_key = var.gemini_api_key
gemini_model = "gemini-2.5-flash"
@@ -118,7 +118,7 @@ For enterprise users who prefer Google's Vertex AI platform:
```tf
module "gemini" {
source = "registry.coder.com/coder-labs/gemini/coder"
version = "2.1.0"
version = "2.1.1"
agent_id = coder_agent.example.id
gemini_api_key = var.gemini_api_key
folder = "/home/coder/project"
@@ -172,6 +172,7 @@ EOT
install_script = file("${path.module}/scripts/install.sh")
start_script = file("${path.module}/scripts/start.sh")
module_dir_name = ".gemini-module"
folder = trimsuffix(var.folder, "/")
}
module "agentapi" {
@@ -179,6 +180,7 @@ module "agentapi" {
version = "1.2.0"
agent_id = var.agent_id
folder = local.folder
web_app_slug = local.app_slug
web_app_order = var.order
web_app_group = var.group
@@ -1,5 +1,5 @@
---
display_name: Amp CLI
display_name: Amp
icon: ../../../../.icons/sourcegraph-amp.svg
description: Sourcegraph's AI coding agent with deep codebase understanding and intelligent code search capabilities
verified: true
@@ -13,7 +13,7 @@ Run [Amp CLI](https://ampcode.com/) in your workspace to access Sourcegraph's AI
```tf
module "amp-cli" {
source = "registry.coder.com/coder-labs/sourcegraph-amp/coder"
version = "1.1.0"
version = "2.0.0"
agent_id = coder_agent.example.id
sourcegraph_amp_api_key = var.sourcegraph_amp_api_key
install_sourcegraph_amp = true
@@ -23,8 +23,10 @@ module "amp-cli" {
## Prerequisites
- Include the [Coder Login](https://registry.coder.com/modules/coder-login/coder) module in your template
- Node.js and npm are automatically installed (via NVM) if not already available
- **Default (official installer)**: No prerequisites - the official installer includes its own runtime (Bun)
- **npm installation (`install_via_npm = true`)**: Requires Node.js and npm to be installed before Amp installation
- Required for Alpine Linux or other musl-based systems
- Ensure Node.js and npm are available in your workspace image or via earlier provisioning steps
## Usage Example
@@ -35,52 +37,55 @@ data "coder_parameter" "ai_prompt" {
type = "string"
default = ""
mutable = true
}
# Set system prompt for Amp CLI via environment variables
resource "coder_agent" "main" {
# ...
env = {
SOURCEGRAPH_AMP_SYSTEM_PROMPT = <<-EOT
You are an Amp assistant that helps developers debug and write code efficiently.
Always log task status to Coder.
EOT
SOURCEGRAPH_AMP_TASK_PROMPT = data.coder_parameter.ai_prompt.value
}
}
variable "sourcegraph_amp_api_key" {
variable "amp_api_key" {
type = string
description = "Sourcegraph Amp API key. Get one at https://ampcode.com/settings"
sensitive = true
}
module "amp-cli" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/sourcegraph-amp/coder"
version = "1.1.0"
agent_id = coder_agent.example.id
sourcegraph_amp_api_key = var.sourcegraph_amp_api_key # recommended for authenticated usage
install_sourcegraph_amp = true
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/sourcegraph-amp/coder"
amp_version = "2.0.0"
agent_id = coder_agent.example.id
amp_api_key = var.amp_api_key # recommended for tasks usage
workdir = "/home/coder/project"
instruction_prompt = <<-EOT
# Instructions
- Start every response with `amp > `
EOT
ai_prompt = data.coder_parameter.ai_prompt.value
base_amp_config = jsonencode({
"amp.anthropic.thinking.enabled" = true
"amp.todos.enabled" = true
"amp.tools.stopTimeout" = 600
"amp.git.commit.ampThread.enabled" = true
"amp.git.commit.coauthor.enabled" = true
"amp.terminal.commands.nodeSpawn.loadProfile" = "daily"
"amp.permissions" = [
{ "tool" : "mcp__coder__*", "action" : "allow" },
{ "tool" : "Bash", "action" : "allow", "context" : "thread" },
{ "tool" : "Bash", "matches" : { "cmd" : ["rm -rf /*", "rm -rf ~/*"] }, "action" : "reject", "context" : "subagent" },
{ "tool" : "edit_file", "action" : "allow" },
{ "tool" : "write_file", "action" : "allow" },
{ "tool" : "read_file", "action" : "allow" },
{ "tool" : "Grep", "action" : "allow" }
]
})
}
```
## How it Works
- **Install**: Installs Sourcegraph Amp CLI using npm (installs Node.js via NVM if required)
- **Start**: Launches Amp CLI in the specified directory, wrapped with AgentAPI to enable tasks and AI interactions
- **Environment Variables**: Sets `SOURCEGRAPH_AMP_API_KEY` and `SOURCEGRAPH_AMP_START_DIRECTORY` for the CLI execution
## Troubleshooting
- If `amp` is not found, ensure `install_sourcegraph_amp = true` and your API key is valid
- Logs are written under `/home/coder/.sourcegraph-amp-module/` (`install.log`, `agentapi-start.log`) for debugging
- If `amp` is not found, ensure `install_amp = true` and your API key is valid
- Logs are written under `/home/coder/.amp-module/` (`install.log`, `agentapi-start.log`) for debugging
- If AgentAPI fails to start, verify that your container has network access and executable permissions for the scripts
> [!IMPORTANT]
> For using **Coder Tasks** with Amp CLI, make sure to pass the `AI Prompt` parameter and set `sourcegraph_amp_api_key`.
> To use tasks with Amp CLI, create a `coder_parameter` named `"AI Prompt"` and pass its value to the amp-cli module's `ai_prompt` variable. The `folder` variable is required for the module to function correctly.
> For using **Coder Tasks** with Amp CLI, make sure to set `amp_api_key`.
> This ensures task reporting and status updates work seamlessly.
## References
@@ -43,9 +43,9 @@ const setup = async (props?: SetupProps): Promise<{ id: string }> => {
const { id } = await setupUtil({
moduleDir: import.meta.dir,
moduleVariables: {
install_sourcegraph_amp: props?.skipAmpMock ? "true" : "false",
workdir: "/home/coder",
install_amp: props?.skipAmpMock ? "true" : "false",
install_agentapi: props?.skipAgentAPIMock ? "true" : "false",
sourcegraph_amp_model: "test-model",
...props?.moduleVariables,
},
registerCleanup,
@@ -68,45 +68,94 @@ const setup = async (props?: SetupProps): Promise<{ id: string }> => {
setDefaultTimeout(60 * 1000);
describe("sourcegraph-amp", async () => {
describe("amp", async () => {
beforeAll(async () => {
await runTerraformInit(import.meta.dir);
});
test("happy-path", async () => {
const { id } = await setup();
// test("happy-path", async () => {
// const { id } = await setup();
// await execModuleScript(id);
// await expectAgentAPIStarted(id);
// });
//
// test("api-key", async () => {
// const apiKey = "test-api-key-123";
// const { id } = await setup({
// moduleVariables: {
// amp_api_key: apiKey,
// },
// });
// await execModuleScript(id);
// const resp = await readFileContainer(
// id,
// "/home/coder/.amp-module/agentapi-start.log",
// );
// expect(resp).toContain("amp_api_key provided !");
// });
//
test("install-latest-version", async () => {
const { id } = await setup({
skipAmpMock: true,
skipAgentAPIMock: true,
moduleVariables: {
amp_version: "",
},
});
await execModuleScript(id);
await expectAgentAPIStarted(id);
});
test("api-key", async () => {
const apiKey = "test-api-key-123";
test("install-specific-version", async () => {
const { id } = await setup({
skipAmpMock: true,
moduleVariables: {
sourcegraph_amp_api_key: apiKey,
amp_version: "0.0.1755964909-g31e083",
},
});
await execModuleScript(id);
const resp = await readFileContainer(
id,
"/home/coder/.sourcegraph-amp-module/agentapi-start.log",
"/home/coder/.amp-module/agentapi-start.log",
);
expect(resp).toContain("sourcegraph_amp_api_key provided !");
expect(resp).toContain("0.0.1755964909-g31e08");
});
test("custom-folder", async () => {
const folder = "/tmp/sourcegraph-amp-test";
test("install-via-npm", async () => {
const { id } = await setup({
skipAmpMock: true,
moduleVariables: {
install_via_npm: "true",
},
});
await execModuleScript(id);
const installLog = await readFileContainer(
id,
"/home/coder/.amp-module/install.log",
);
expect(installLog).toContain("Installing Amp via npm");
const startLog = await readFileContainer(
id,
"/home/coder/.amp-module/agentapi-start.log",
);
expect(startLog).toContain("AMP version:");
});
test("custom-workdir", async () => {
const workdir = "/tmp/amp-test";
const { id } = await setup({
moduleVariables: {
folder,
workdir,
},
});
await execModuleScript(id);
const resp = await readFileContainer(
id,
"/home/coder/.sourcegraph-amp-module/install.log",
"/home/coder/.amp-module/agentapi-start.log",
);
expect(resp).toContain(folder);
expect(resp).toContain(workdir);
});
test("pre-post-install-scripts", async () => {
@@ -119,39 +168,104 @@ describe("sourcegraph-amp", async () => {
await execModuleScript(id);
const preLog = await readFileContainer(
id,
"/home/coder/.sourcegraph-amp-module/pre_install.log",
"/home/coder/.amp-module/pre_install.log",
);
expect(preLog).toContain("pre-install-script");
const postLog = await readFileContainer(
id,
"/home/coder/.sourcegraph-amp-module/post_install.log",
"/home/coder/.amp-module/post_install.log",
);
expect(postLog).toContain("post-install-script");
});
test("system-prompt", async () => {
const prompt = "this is a system prompt for AMP";
const { id } = await setup();
await execModuleScript(id, {
SOURCEGRAPH_AMP_SYSTEM_PROMPT: prompt,
test("instruction-prompt", async () => {
const prompt = "this is a instruction prompt for AMP";
const { id } = await setup({
moduleVariables: {
instruction_prompt: prompt,
},
});
const resp = await readFileContainer(
id,
"/home/coder/.sourcegraph-amp-module/SYSTEM_PROMPT.md",
);
await execModuleScript(id);
const resp = await readFileContainer(id, "/home/coder/.config/AGENTS.md");
expect(resp).toContain(prompt);
});
test("task-prompt", async () => {
test("ai-prompt", async () => {
const prompt = "this is a task prompt for AMP";
const { id } = await setup();
await execModuleScript(id, {
SOURCEGRAPH_AMP_TASK_PROMPT: prompt,
const { id } = await setup({
moduleVariables: {
ai_prompt: prompt,
},
});
await execModuleScript(id);
const resp = await readFileContainer(
id,
"/home/coder/.sourcegraph-amp-module/agentapi-start.log",
"/home/coder/.amp-module/agentapi-start.log",
);
expect(resp).toContain(`sourcegraph amp task prompt provided : ${prompt}`);
expect(resp).toContain(`amp task prompt provided : ${prompt}`);
});
test("custom-base-config", async () => {
const customConfig = JSON.stringify({
"amp.anthropic.thinking.enabled": false,
"amp.todos.enabled": false,
"amp.tools.stopTimeout": 900,
"amp.git.commit.ampThread.enabled": true,
});
const customMcp = JSON.stringify({
"test-server": {
command: "/usr/bin/test-mcp",
args: ["--test-arg"],
type: "stdio",
},
});
const { id } = await setup({
moduleVariables: {
base_amp_config: customConfig,
mcp: customMcp,
},
});
await execModuleScript(id, {
CODER_AGENT_TOKEN: "test-token",
CODER_AGENT_URL: "http://test-url:3000",
});
const settingsContent = await readFileContainer(
id,
"/home/coder/.config/amp/settings.json",
);
const settings = JSON.parse(settingsContent);
expect(settings["amp.anthropic.thinking.enabled"]).toBe(false);
expect(settings["amp.todos.enabled"]).toBe(false);
expect(settings["amp.tools.stopTimeout"]).toBe(900);
expect(settings["amp.git.commit.ampThread.enabled"]).toBe(true);
expect(settings["amp.mcpServers"]).toBeDefined();
expect(settings["amp.mcpServers"].coder).toBeDefined();
expect(settings["amp.mcpServers"]["test-server"]).toBeDefined();
expect(settings["amp.mcpServers"]["test-server"].command).toBe(
"/usr/bin/test-mcp",
);
expect(settings["amp.mcpServers"]["test-server"].args).toEqual([
"--test-arg",
]);
});
test("default-base-config", async () => {
const { id } = await setup();
await execModuleScript(id, {
CODER_AGENT_TOKEN: "test-token",
CODER_AGENT_URL: "http://test-url:3000",
});
const settingsContent = await readFileContainer(
id,
"/home/coder/.config/amp/settings.json",
);
const settings = JSON.parse(settingsContent);
expect(settings["amp.anthropic.thinking.enabled"]).toBe(true);
expect(settings["amp.todos.enabled"]).toBe(true);
expect(settings["amp.mcpServers"]).toBeDefined();
expect(settings["amp.mcpServers"].coder).toBeDefined();
expect(settings["amp.mcpServers"].coder.command).toBe("coder");
});
});
@@ -36,28 +36,9 @@ variable "icon" {
default = "/icon/sourcegraph-amp.svg"
}
variable "folder" {
variable "workdir" {
type = string
description = "The folder to run sourcegraph_amp in."
default = "/home/coder"
}
variable "install_sourcegraph_amp" {
type = bool
description = "Whether to install sourcegraph-amp."
default = true
}
variable "sourcegraph_amp_api_key" {
type = string
description = "sourcegraph-amp API Key"
default = ""
}
resource "coder_env" "sourcegraph_amp_api_key" {
agent_id = var.agent_id
name = "SOURCEGRAPH_AMP_API_KEY"
value = var.sourcegraph_amp_api_key
description = "The folder to run AMP CLI in."
}
variable "install_agentapi" {
@@ -72,18 +53,84 @@ variable "agentapi_version" {
default = "v0.10.0"
}
variable "cli_app" {
type = bool
description = "Whether to create a CLI app for Claude Code"
default = false
}
variable "web_app_display_name" {
type = string
description = "Display name for the web app"
default = "Amp"
}
variable "cli_app_display_name" {
type = string
description = "Display name for the CLI app"
default = "Amp CLI"
}
variable "pre_install_script" {
type = string
description = "Custom script to run before installing sourcegraph_amp"
description = "Custom script to run before installing amp cli"
default = null
}
variable "post_install_script" {
type = string
description = "Custom script to run after installing sourcegraph_amp."
description = "Custom script to run after installing amp cli."
default = null
}
variable "report_tasks" {
type = bool
description = "Whether to enable task reporting to Coder UI"
default = true
}
variable "install_amp" {
type = bool
description = "Whether to install amp cli."
default = true
}
variable "install_via_npm" {
type = bool
description = "Install Amp via npm instead of the official installer."
default = false
}
variable "amp_api_key" {
type = string
description = "amp cli API Key"
default = ""
}
variable "amp_version" {
type = string
description = "The version of amp cli to install."
default = ""
}
variable "ai_prompt" {
type = string
description = "Task prompt for the Amp CLI"
default = ""
}
variable "instruction_prompt" {
type = string
description = "Instruction prompt for the Amp CLI. https://ampcode.com/manual#AGENTS.md"
default = ""
}
resource "coder_env" "amp_api_key" {
agent_id = var.agent_id
name = "AMP_API_KEY"
value = var.amp_api_key
}
variable "base_amp_config" {
type = string
description = <<-EOT
@@ -102,22 +149,25 @@ variable "base_amp_config" {
default = ""
}
variable "additional_mcp_servers" {
variable "mcp" {
type = string
description = "Additional MCP servers configuration in JSON format to append to amp.mcpServers."
default = null
}
data "external" "env" {
program = ["sh", "-c", "echo '{\"CODER_AGENT_TOKEN\":\"'$CODER_AGENT_TOKEN'\",\"CODER_AGENT_URL\":\"'$CODER_AGENT_URL'\"}'"]
}
locals {
app_slug = "amp"
default_base_config = {
default_base_config = jsonencode({
"amp.anthropic.thinking.enabled" = true
"amp.todos.enabled" = true
}
})
# Use provided config or default, then extract base settings (excluding mcpServers)
user_config = var.base_amp_config != "" ? jsondecode(var.base_amp_config) : local.default_base_config
user_config = jsondecode(var.base_amp_config != "" ? var.base_amp_config : local.default_base_config)
base_amp_settings = { for k, v in local.user_config : k => v if k != "amp.mcpServers" }
coder_mcp = {
@@ -125,14 +175,16 @@ locals {
"command" = "coder"
"args" = ["exp", "mcp", "server"]
"env" = {
"CODER_MCP_APP_STATUS_SLUG" = local.app_slug
"CODER_MCP_AI_AGENTAPI_URL" = "http://localhost:3284"
"CODER_MCP_APP_STATUS_SLUG" = var.report_tasks == true ? local.app_slug : ""
"CODER_MCP_AI_AGENTAPI_URL" = var.report_tasks == true ? "http://localhost:3284" : ""
"CODER_AGENT_TOKEN" = data.external.env.result.CODER_AGENT_TOKEN
"CODER_AGENT_URL" = data.external.env.result.CODER_AGENT_URL
}
"type" = "stdio"
}
}
additional_mcp = var.additional_mcp_servers != null ? jsondecode(var.additional_mcp_servers) : {}
additional_mcp = var.mcp != null ? jsondecode(var.mcp) : {}
merged_mcp_servers = merge(
lookup(local.user_config, "amp.mcpServers", {}),
@@ -146,7 +198,8 @@ locals {
install_script = file("${path.module}/scripts/install.sh")
start_script = file("${path.module}/scripts/start.sh")
module_dir_name = ".sourcegraph-amp-module"
module_dir_name = ".amp-module"
workdir = trimsuffix(var.workdir, "/")
}
module "agentapi" {
@@ -154,13 +207,15 @@ module "agentapi" {
version = "1.2.0"
agent_id = var.agent_id
folder = local.workdir
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 = "Sourcegraph Amp"
cli_app_slug = "${local.app_slug}-cli"
cli_app_display_name = "Sourcegraph Amp CLI"
web_app_display_name = var.web_app_display_name
cli_app = var.cli_app
cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null
cli_app_display_name = var.cli_app ? var.cli_app_display_name : null
module_dir_name = local.module_dir_name
install_agentapi = var.install_agentapi
agentapi_version = var.agentapi_version
@@ -173,8 +228,10 @@ module "agentapi" {
echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh
chmod +x /tmp/start.sh
SOURCEGRAPH_AMP_API_KEY='${var.sourcegraph_amp_api_key}' \
SOURCEGRAPH_AMP_START_DIRECTORY='${var.folder}' \
ARG_AMP_API_KEY='${var.amp_api_key}' \
ARG_AMP_START_DIRECTORY='${var.workdir}' \
ARG_AMP_TASK_PROMPT='${base64encode(var.ai_prompt)}' \
ARG_REPORT_TASKS='${var.report_tasks}' \
/tmp/start.sh
EOT
@@ -185,9 +242,11 @@ module "agentapi" {
echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh
chmod +x /tmp/install.sh
ARG_INSTALL_SOURCEGRAPH_AMP='${var.install_sourcegraph_amp}' \
SOURCEGRAPH_AMP_START_DIRECTORY='${var.folder}' \
ARG_AMP_CONFIG="$(echo -n '${base64encode(jsonencode(local.final_config))}' | base64 -d)" \
ARG_INSTALL_AMP='${var.install_amp}' \
ARG_INSTALL_VIA_NPM='${var.install_via_npm}' \
ARG_AMP_CONFIG="${base64encode(jsonencode(local.final_config))}" \
ARG_AMP_VERSION='${var.amp_version}' \
ARG_AMP_INSTRUCTION_PROMPT='${base64encode(var.instruction_prompt)}' \
/tmp/install.sh
EOT
}
@@ -1,77 +1,119 @@
#!/bin/bash
set -euo pipefail
source "$HOME"/.bashrc
# ANSI colors
BOLD='\033[1m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
ARG_INSTALL_AMP=${ARG_INSTALL_AMP:-true}
ARG_INSTALL_VIA_NPM=${ARG_INSTALL_VIA_NPM:-false}
ARG_AMP_VERSION=${ARG_AMP_VERSION:-}
ARG_AMP_INSTRUCTION_PROMPT=$(echo -n "${ARG_AMP_INSTRUCTION_PROMPT:-}" | base64 -d)
ARG_AMP_CONFIG=$(echo -n "${ARG_AMP_CONFIG:-}" | base64 -d)
echo "--------------------------------"
echo "Install flag: $ARG_INSTALL_SOURCEGRAPH_AMP"
echo "Workspace: $SOURCEGRAPH_AMP_START_DIRECTORY"
printf "Install flag: %s\n" "$ARG_INSTALL_AMP"
printf "Install via npm: %s\n" "$ARG_INSTALL_VIA_NPM"
printf "Amp Version: %s\n" "$ARG_AMP_VERSION"
printf "AMP Config: %s\n" "$ARG_AMP_CONFIG"
printf "Instruction Prompt: %s\n" "$ARG_AMP_INSTRUCTION_PROMPT"
echo "--------------------------------"
# Helper function to check if a command exists
command_exists() {
command -v "$1" > /dev/null 2>&1
}
function install_node() {
if ! command_exists npm; then
printf "npm not found, checking for Node.js installation...\n"
if ! command_exists node; then
printf "Node.js not found, installing Node.js via NVM...\n"
export NVM_DIR="$HOME/.nvm"
if [ ! -d "$NVM_DIR" ]; then
mkdir -p "$NVM_DIR"
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
else
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
fi
install_amp_npm() {
printf "%s${YELLOW}Installing Amp via npm${NC}\n" "${BOLD}"
# Temporarily disable nounset (-u) for nvm to avoid PROVIDED_VERSION error
set +u
nvm install --lts
nvm use --lts
nvm alias default node
set -u
printf "Node.js installed: %s\n" "$(node --version)"
printf "npm installed: %s\n" "$(npm --version)"
else
printf "Node.js is installed but npm is not available. Please install npm manually.\n"
exit 1
fi
# Load nvm if available
# shellcheck source=/dev/null
if [ -f "$HOME/.nvm/nvm.sh" ]; then
source "$HOME/.nvm/nvm.sh"
fi
}
function install_sourcegraph_amp() {
if [ "${ARG_INSTALL_SOURCEGRAPH_AMP}" = "true" ]; then
install_node
# If nvm is not used, set up user npm global directory
if ! command_exists nvm; then
mkdir -p "$HOME/.npm-global"
npm config set prefix "$HOME/.npm-global"
export PATH="$HOME/.npm-global/bin:$PATH"
if ! grep -q "export PATH=$HOME/.npm-global/bin:\$PATH" ~/.bashrc; then
echo "export PATH=$HOME/.npm-global/bin:\$PATH" >> ~/.bashrc
fi
fi
printf "%s Installing Sourcegraph AMP CLI...\n" "${BOLD}"
npm install -g @sourcegraph/amp@0.0.1754179307-gba1f97
printf "%s Successfully installed Sourcegraph AMP CLI. Version: %s\n" "${BOLD}" "$(amp --version)"
if ! command_exists node || ! command_exists npm; then
printf "${YELLOW}Warning: Node.js/npm not found. Skipping Amp installation.${NC}\n"
printf "To install Amp via npm, please install Node.js and npm first.\n"
return 1
fi
}
function setup_system_prompt() {
if [ -n "${SOURCEGRAPH_AMP_SYSTEM_PROMPT:-}" ]; then
echo "Setting Sourcegraph AMP system prompt..."
mkdir -p "$HOME/.sourcegraph-amp-module"
echo "$SOURCEGRAPH_AMP_SYSTEM_PROMPT" > "$HOME/.sourcegraph-amp-module/SYSTEM_PROMPT.md"
echo "System prompt saved to $HOME/.sourcegraph-amp-module/SYSTEM_PROMPT.md"
printf "Node.js version: %s\n" "$(node --version)"
printf "npm version: %s\n" "$(npm --version)"
NPM_GLOBAL_PREFIX="${HOME}/.npm-global"
if [ ! -d "$NPM_GLOBAL_PREFIX" ]; then
mkdir -p "$NPM_GLOBAL_PREFIX"
fi
npm config set prefix "$NPM_GLOBAL_PREFIX"
export PATH="$NPM_GLOBAL_PREFIX/bin:$PATH"
if [ -n "$ARG_AMP_VERSION" ]; then
npm install -g "@sourcegraph/amp@$ARG_AMP_VERSION"
else
echo "No system prompt provided for Sourcegraph AMP."
npm install -g "@sourcegraph/amp"
fi
if ! grep -q 'export PATH="$HOME/.npm-global/bin:$PATH"' "$HOME/.bashrc"; then
echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> "$HOME/.bashrc"
fi
}
install_amp_official() {
printf "%s Installing Amp using official installer\n" "${BOLD}"
if [ -n "$ARG_AMP_VERSION" ]; then
export AMP_VERSION="$ARG_AMP_VERSION"
printf "Installing Amp version: %s\n" "$AMP_VERSION"
fi
if curl -fsSL https://ampcode.com/install.sh | bash; then
export PATH="$HOME/.local/bin:$HOME/.amp/bin:$PATH"
if ! grep -q 'export PATH="$HOME/.local/bin:$PATH"' "$HOME/.bashrc"; then
echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$HOME/.bashrc"
fi
else
printf "${YELLOW}Warning: Official installer failed. Installation skipped.${NC}\n"
return 1
fi
}
function install_amp() {
if [ "${ARG_INSTALL_AMP}" = "true" ]; then
if [ "${ARG_INSTALL_VIA_NPM}" = "true" ]; then
install_amp_npm || {
printf "${YELLOW}Amp installation via npm failed.${NC}\n"
return 0
}
else
install_amp_official || {
printf "${YELLOW}Amp installation via official installer failed.${NC}\n"
return 0
}
fi
if command_exists amp; then
printf "%s${GREEN}Successfully installed Sourcegraph Amp CLI. Version: %s${NC}\n" "${BOLD}" "$(amp --version)"
fi
else
printf "Skipping Sourcegraph Amp CLI installation (install_amp=false)\n"
fi
}
function setup_instruction_prompt() {
if [ -n "${ARG_AMP_INSTRUCTION_PROMPT:-}" ]; then
echo "Setting AMP instruction prompt..."
mkdir -p "$HOME/.config"
echo "$ARG_AMP_INSTRUCTION_PROMPT" > "$HOME/.config/AGENTS.md"
echo "Instruction prompt saved to $HOME/.config/AGENTS.md"
else
echo "No instruction prompt provided for Sourcegraph AMP."
fi
}
@@ -86,11 +128,17 @@ function configure_amp_settings() {
fi
echo "Writing AMP configuration to $SETTINGS_PATH"
printf '%s\n' "$ARG_AMP_CONFIG" > "$SETTINGS_PATH"
UPDATED_CONFIG=$(echo "$ARG_AMP_CONFIG" | jq --arg token "$CODER_AGENT_TOKEN" --arg url "$CODER_AGENT_URL" \
".[\"amp.mcpServers\"].coder.env += {
\"CODER_AGENT_TOKEN\": \"$CODER_AGENT_TOKEN\",
\"CODER_AGENT_URL\": \"$CODER_AGENT_URL\"
}")
printf "UPDATED_CONFIG: %s\n" "$UPDATED_CONFIG"
printf '%s\n' "$UPDATED_CONFIG" > "$SETTINGS_PATH"
echo "AMP configuration complete"
}
install_sourcegraph_amp
setup_system_prompt
install_amp
setup_instruction_prompt
configure_amp_settings
@@ -6,11 +6,11 @@ set -euo pipefail
source "$HOME/.bashrc"
# shellcheck source=/dev/null
if [ -f "$HOME/.nvm/nvm.sh" ]; then
source "$HOME"/.nvm/nvm.sh
else
export PATH="$HOME/.npm-global/bin:$PATH"
source "$HOME/.nvm/nvm.sh"
fi
export PATH="$HOME/.local/bin:$HOME/.amp/bin:$HOME/.npm-global/bin:$PATH"
function ensure_command() {
command -v "$1" &> /dev/null || {
echo "Error: '$1' not found." >&2
@@ -18,10 +18,21 @@ function ensure_command() {
}
}
ARG_AMP_START_DIRECTORY=${ARG_AMP_START_DIRECTORY:-"$HOME"}
ARG_AMP_API_KEY=${ARG_AMP_API_KEY:-}
ARG_AMP_TASK_PROMPT=$(echo -n "${ARG_AMP_TASK_PROMPT:-}" | base64 -d)
ARG_REPORT_TASKS=${ARG_REPORT_TASKS:-true}
echo "--------------------------------"
printf "Workspace: %s\n" "$ARG_AMP_START_DIRECTORY"
printf "Task Prompt: %s\n" "$ARG_AMP_TASK_PROMPT"
printf "ARG_REPORT_TASKS: %s\n" "$ARG_REPORT_TASKS"
echo "--------------------------------"
ensure_command amp
echo "AMP version: $(amp --version)"
dir="$SOURCEGRAPH_AMP_START_DIRECTORY"
dir="$ARG_AMP_START_DIRECTORY"
if [[ -d "$dir" ]]; then
echo "Using existing directory: $dir"
else
@@ -30,20 +41,23 @@ else
fi
cd "$dir"
if [ -n "$SOURCEGRAPH_AMP_API_KEY" ]; then
printf "sourcegraph_amp_api_key provided !\n"
export AMP_API_KEY=$SOURCEGRAPH_AMP_API_KEY
if [ -n "$ARG_AMP_API_KEY" ]; then
printf "amp_api_key provided !\n"
export AMP_API_KEY=$ARG_AMP_API_KEY
else
printf "sourcegraph_amp_api_key not provided\n"
printf "amp_api_key not provided\n"
fi
if [ -n "${SOURCEGRAPH_AMP_TASK_PROMPT:-}" ]; then
printf "sourcegraph amp task prompt provided : $SOURCEGRAPH_AMP_TASK_PROMPT"
PROMPT="Every step of the way, report tasks to Coder with proper descriptions and statuses. Your task at hand: $SOURCEGRAPH_AMP_TASK_PROMPT"
if [ -n "$ARG_AMP_TASK_PROMPT" ]; then
if [ "$ARG_REPORT_TASKS" == "true" ]; then
printf "amp task prompt provided : %s" "$ARG_AMP_TASK_PROMPT\n"
PROMPT="Every step of the way, report your progress using coder_report_task tool with proper summary and statuses. Your task at hand: $ARG_AMP_TASK_PROMPT"
else
PROMPT="$ARG_AMP_TASK_PROMPT"
fi
# Pipe the prompt into amp, which will be run inside agentapi
agentapi server --term-width=67 --term-height=1190 -- bash -c "echo \"$PROMPT\" | amp"
agentapi server --type amp --term-width=67 --term-height=1190 -- bash -c "echo \"$PROMPT\" | amp"
else
printf "No task prompt given.\n"
agentapi server --term-width=67 --term-height=1190 -- amp
agentapi server --type amp --term-width=67 --term-height=1190 -- amp
fi
+9 -9
View File
@@ -13,7 +13,7 @@ Run [Amazon Q](https://aws.amazon.com/q/) in your workspace to access Amazon's A
```tf
module "amazon-q" {
source = "registry.coder.com/coder/amazon-q/coder"
version = "2.1.0"
version = "2.1.1"
agent_id = coder_agent.example.id
workdir = "/home/coder"
@@ -102,7 +102,7 @@ data "coder_parameter" "ai_prompt" {
module "amazon-q" {
source = "registry.coder.com/coder/amazon-q/coder"
version = "2.1.0"
version = "2.1.1"
agent_id = coder_agent.example.id
workdir = "/home/coder"
auth_tarball = var.amazon_q_auth_tarball
@@ -228,7 +228,7 @@ If no custom `agent_config` is provided, the default agent name "agent" is used.
```tf
module "amazon-q" {
source = "registry.coder.com/coder/amazon-q/coder"
version = "2.1.0"
version = "2.1.1"
agent_id = coder_agent.example.id
workdir = "/home/coder"
auth_tarball = var.amazon_q_auth_tarball
@@ -258,7 +258,7 @@ This example will:
```tf
module "amazon-q" {
source = "registry.coder.com/coder/amazon-q/coder"
version = "2.1.0"
version = "2.1.1"
agent_id = coder_agent.example.id
workdir = "/home/coder"
auth_tarball = var.amazon_q_auth_tarball
@@ -279,7 +279,7 @@ module "amazon-q" {
```tf
module "amazon-q" {
source = "registry.coder.com/coder/amazon-q/coder"
version = "2.1.0"
version = "2.1.1"
agent_id = coder_agent.example.id
workdir = "/home/coder"
auth_tarball = var.amazon_q_auth_tarball
@@ -305,7 +305,7 @@ module "amazon-q" {
```tf
module "amazon-q" {
source = "registry.coder.com/coder/amazon-q/coder"
version = "2.1.0"
version = "2.1.1"
agent_id = coder_agent.example.id
workdir = "/home/coder"
auth_tarball = var.amazon_q_auth_tarball
@@ -319,7 +319,7 @@ module "amazon-q" {
```tf
module "amazon-q" {
source = "registry.coder.com/coder/amazon-q/coder"
version = "2.1.0"
version = "2.1.1"
agent_id = coder_agent.example.id
workdir = "/home/coder"
auth_tarball = var.amazon_q_auth_tarball
@@ -340,7 +340,7 @@ module "amazon-q" {
```tf
module "amazon-q" {
source = "registry.coder.com/coder/amazon-q/coder"
version = "2.1.0"
version = "2.1.1"
agent_id = coder_agent.example.id
workdir = "/home/coder"
auth_tarball = var.amazon_q_auth_tarball
@@ -358,7 +358,7 @@ For environments without direct internet access, you can host Amazon Q installat
```tf
module "amazon-q" {
source = "registry.coder.com/coder/amazon-q/coder"
version = "2.1.0"
version = "2.1.1"
agent_id = coder_agent.example.id
workdir = "/home/coder"
auth_tarball = var.amazon_q_auth_tarball
+2 -2
View File
@@ -96,8 +96,6 @@ variable "workdir" {
description = "The folder to run Amazon Q in."
}
# ---------------------------------------------
variable "install_amazon_q" {
type = bool
description = "Whether to install Amazon Q."
@@ -190,6 +188,7 @@ resource "coder_env" "auth_tarball" {
locals {
app_slug = "amazonq"
workdir = trimsuffix(var.workdir, "/")
install_script = file("${path.module}/scripts/install.sh")
start_script = file("${path.module}/scripts/start.sh")
module_dir_name = ".amazonq-module"
@@ -218,6 +217,7 @@ module "agentapi" {
version = "1.2.0"
agent_id = var.agent_id
folder = local.workdir
web_app_slug = local.app_slug
web_app_order = var.order
web_app_group = var.group
@@ -94,6 +94,13 @@ function install_amazon_q() {
function extract_auth_tarball() {
if [ -n "$ARG_AUTH_TARBALL" ]; then
echo "Extracting auth tarball..."
if ! command_exists zstd; then
echo "Error: zstd is required to extract the authentication tarball but is not installed."
echo "Please install zstd using the pre_install_script parameter."
exit 1
fi
PREV_DIR="$PWD"
echo "$ARG_AUTH_TARBALL" | base64 -d > /tmp/auth.tar.zst
rm -rf ~/.local/share/amazon-q
+159 -4
View File
@@ -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 = "3.1.0"
version = "3.1.1"
agent_id = coder_agent.example.id
workdir = "/home/coder/project"
claude_api_key = "xxxx-xxxxx-xxxx"
@@ -49,7 +49,7 @@ data "coder_parameter" "ai_prompt" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "3.1.0"
version = "3.1.1"
agent_id = coder_agent.example.id
workdir = "/home/coder/project"
@@ -85,7 +85,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 = "3.1.0"
version = "3.1.1"
agent_id = coder_agent.example.id
workdir = "/home/coder"
install_claude_code = true
@@ -108,13 +108,168 @@ variable "claude_code_oauth_token" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "3.0.3"
version = "3.1.1"
agent_id = coder_agent.example.id
workdir = "/home/coder/project"
claude_code_oauth_token = var.claude_code_oauth_token
}
```
### Usage with AWS Bedrock
#### Prerequisites
AWS account with Bedrock access, Claude models enabled in Bedrock console, appropriate IAM permissions.
Configure Claude Code to use AWS Bedrock for accessing Claude models through your AWS infrastructure.
```tf
resource "coder_env" "bedrock_use" {
agent_id = coder_agent.example.id
name = "CLAUDE_CODE_USE_BEDROCK"
value = "1"
}
resource "coder_env" "aws_region" {
agent_id = coder_agent.example.id
name = "AWS_REGION"
value = "us-east-1" # Choose your preferred region
}
# Option 1: Using AWS credentials
variable "aws_access_key_id" {
type = string
description = "Your AWS access key ID. Create this in the AWS IAM console under 'Security credentials'."
sensitive = true
value = "xxxx-xxx-xxxx"
}
variable "aws_secret_access_key" {
type = string
description = "Your AWS secret access key. This is shown once when you create an access key in the AWS IAM console."
sensitive = true
value = "xxxx-xxx-xxxx"
}
resource "coder_env" "aws_access_key_id" {
agent_id = coder_agent.example.id
name = "AWS_ACCESS_KEY_ID"
value = var.aws_access_key_id
}
resource "coder_env" "aws_secret_access_key" {
agent_id = coder_agent.example.id
name = "AWS_SECRET_ACCESS_KEY"
value = var.aws_secret_access_key
}
# Option 2: Using Bedrock API key (simpler)
variable "aws_bearer_token_bedrock" {
type = string
description = "Your AWS Bedrock bearer token. This provides access to Bedrock without needing separate access key and secret key."
sensitive = true
value = "xxxx-xxx-xxxx"
}
resource "coder_env" "bedrock_api_key" {
agent_id = coder_agent.example.id
name = "AWS_BEARER_TOKEN_BEDROCK"
value = var.aws_bearer_token_bedrock
}
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "3.1.1"
agent_id = coder_agent.example.id
workdir = "/home/coder/project"
model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
}
```
> [!NOTE]
> For additional Bedrock configuration options (model selection, token limits, region overrides, etc.), see the [Claude Code Bedrock documentation](https://docs.claude.com/en/docs/claude-code/amazon-bedrock).
### Usage with Google Vertex AI
#### Prerequisites
GCP project with Vertex AI API enabled, Claude models enabled through Model Garden, service account with Vertex AI permissions, appropriate IAM permissions (Vertex AI User role).
Configure Claude Code to use Google Vertex AI for accessing Claude models through Google Cloud Platform.
```tf
variable "vertex_sa_json" {
type = string
description = "The complete JSON content of your Google Cloud service account key file. Create a service account in the GCP Console under 'IAM & Admin > Service Accounts', then create and download a JSON key. Copy the entire JSON content into this variable."
sensitive = true
}
resource "coder_env" "vertex_use" {
agent_id = coder_agent.example.id
name = "CLAUDE_CODE_USE_VERTEX"
value = "1"
}
resource "coder_env" "vertex_project_id" {
agent_id = coder_agent.example.id
name = "ANTHROPIC_VERTEX_PROJECT_ID"
value = "your-gcp-project-id"
}
resource "coder_env" "cloud_ml_region" {
agent_id = coder_agent.example.id
name = "CLOUD_ML_REGION"
value = "global"
}
resource "coder_env" "vertex_sa_json" {
agent_id = coder_agent.example.id
name = "VERTEX_SA_JSON"
value = var.vertex_sa_json
}
resource "coder_env" "google_application_credentials" {
agent_id = coder_agent.example.id
name = "GOOGLE_APPLICATION_CREDENTIALS"
value = "/tmp/gcp-sa.json"
}
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "3.1.1"
agent_id = coder_agent.example.id
workdir = "/home/coder/project"
model = "claude-sonnet-4@20250514"
pre_install_script = <<-EOT
#!/bin/bash
# Write the service account JSON to a file
echo "$VERTEX_SA_JSON" > /tmp/gcp-sa.json
# Install prerequisite packages
sudo apt-get update
sudo apt-get install -y apt-transport-https ca-certificates gnupg curl
# Add Google Cloud public key
curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | sudo gpg --dearmor -o /usr/share/keyrings/cloud.google.gpg
# Add Google Cloud SDK repo to apt sources
echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" | sudo tee /etc/apt/sources.list.d/google-cloud-sdk.list
# Update and install the Google Cloud SDK
sudo apt-get update && sudo apt-get install -y google-cloud-cli
# Authenticate gcloud with the service account
gcloud auth activate-service-account --key-file=/tmp/gcp-sa.json
EOT
}
```
> [!NOTE]
> For additional Vertex AI configuration options (model selection, token limits, region overrides, etc.), see the [Claude Code Vertex AI documentation](https://docs.claude.com/en/docs/claude-code/google-vertex-ai).
## Troubleshooting
If you encounter any issues, check the log files in the `~/.claude-module` directory within your workspace for detailed information.
+33 -10
View File
@@ -14,7 +14,7 @@ This module allows you to automatically clone a repository by URL and skip if it
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.1.2"
version = "1.2.0"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
}
@@ -28,7 +28,7 @@ module "git-clone" {
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.1.2"
version = "1.2.0"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
base_dir = "~/projects/coder"
@@ -43,7 +43,7 @@ To use with [Git Authentication](https://coder.com/docs/v2/latest/admin/git-prov
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.1.2"
version = "1.2.0"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
}
@@ -69,7 +69,7 @@ data "coder_parameter" "git_repo" {
module "git_clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.1.2"
version = "1.2.0"
agent_id = coder_agent.example.id
url = data.coder_parameter.git_repo.value
}
@@ -103,7 +103,7 @@ Configuring `git-clone` for a self-hosted GitHub Enterprise Server running at `g
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.1.2"
version = "1.2.0"
agent_id = coder_agent.example.id
url = "https://github.example.com/coder/coder/tree/feat/example"
git_providers = {
@@ -122,7 +122,7 @@ To GitLab clone with a specific branch like `feat/example`
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.1.2"
version = "1.2.0"
agent_id = coder_agent.example.id
url = "https://gitlab.com/coder/coder/-/tree/feat/example"
}
@@ -134,7 +134,7 @@ Configuring `git-clone` for a self-hosted GitLab running at `gitlab.example.com`
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.1.2"
version = "1.2.0"
agent_id = coder_agent.example.id
url = "https://gitlab.example.com/coder/coder/-/tree/feat/example"
git_providers = {
@@ -155,7 +155,7 @@ For example, to clone the `feat/example` branch:
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.1.2"
version = "1.2.0"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
branch_name = "feat/example"
@@ -173,7 +173,7 @@ For example, this will clone into the `~/projects/coder/coder-dev` folder:
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.1.2"
version = "1.2.0"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
folder_name = "coder-dev"
@@ -192,9 +192,32 @@ If not defined, the default, `0`, performs a full clone.
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/git-clone/coder"
version = "1.1.0"
version = "1.2.0"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
depth = 1
}
```
## Post-clone script
Run a custom script after cloning the repository by setting the `post_clone_script` variable.
This is useful for running initialization tasks like installing dependencies or setting up the environment.
```tf
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.2.0"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
post_clone_script = <<-EOT
#!/bin/bash
echo "Repository cloned successfully!"
# Install dependencies
npm install
# Run any other initialization tasks
make setup
EOT
}
```
+18 -1
View File
@@ -30,11 +30,12 @@ describe("git-clone", async () => {
url: "fake-url",
});
const output = await executeScriptInContainer(state, "alpine/git");
expect(output.exitCode).toBe(128);
expect(output.stdout).toEqual([
"Creating directory ~/fake-url...",
"Cloning fake-url to ~/fake-url...",
]);
expect(output.stderr.join(" ")).toContain("fatal");
expect(output.stderr.join(" ")).toContain("fake-url");
});
it("repo_dir should match repo name for https", async () => {
@@ -244,4 +245,20 @@ describe("git-clone", async () => {
"Cloning https://github.com/michaelbrewer/repo-tests.log to ~/repo-tests.log on branch feat/branch...",
]);
});
it("runs post-clone script", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
url: "fake-url",
post_clone_script: "echo 'Post-clone script executed'",
});
const output = await executeScriptInContainer(
state,
"alpine/git",
"sh",
"mkdir -p ~/fake-url && echo 'existing' > ~/fake-url/file.txt",
);
expect(output.stdout).toContain("Running post-clone script...");
expect(output.stdout).toContain("Post-clone script executed");
});
});
+9
View File
@@ -62,6 +62,12 @@ variable "depth" {
default = 0
}
variable "post_clone_script" {
description = "Custom script to run after cloning the repository. Runs always after git clone, even if the repository already exists."
type = string
default = null
}
locals {
# Remove query parameters and fragments from the URL
url = replace(replace(var.url, "/\\?.*/", ""), "/#.*/", "")
@@ -81,6 +87,8 @@ locals {
clone_path = var.base_dir != "" ? join("/", [var.base_dir, local.folder_name]) : join("/", ["~", local.folder_name])
# Construct the web URL
web_url = startswith(local.clone_url, "git@") ? replace(replace(local.clone_url, ":", "/"), "git@", "https://") : local.clone_url
# Encode the post_clone_script for passing to the shell script
encoded_post_clone_script = var.post_clone_script != null ? base64encode(var.post_clone_script) : ""
}
output "repo_dir" {
@@ -120,6 +128,7 @@ resource "coder_script" "git_clone" {
REPO_URL : local.clone_url,
BRANCH_NAME : local.branch_name,
DEPTH = var.depth,
POST_CLONE_SCRIPT : local.encoded_post_clone_script,
})
display_name = "Git Clone"
icon = "/icon/git.svg"
+11 -1
View File
@@ -6,6 +6,7 @@ BRANCH_NAME="${BRANCH_NAME}"
# Expand home if it's specified!
CLONE_PATH="$${CLONE_PATH/#\~/$${HOME}}"
DEPTH="${DEPTH}"
POST_CLONE_SCRIPT="${POST_CLONE_SCRIPT}"
# Check if the variable is empty...
if [ -z "$REPO_URL" ]; then
@@ -52,5 +53,14 @@ if [ -z "$(ls -A "$CLONE_PATH")" ]; then
fi
else
echo "$CLONE_PATH already exists and isn't empty, skipping clone!"
exit 0
fi
# Run post-clone script if provided
if [ -n "$POST_CLONE_SCRIPT" ]; then
echo "Running post-clone script..."
echo "$POST_CLONE_SCRIPT" | base64 -d > /tmp/post_clone.sh
chmod +x /tmp/post_clone.sh
cd "$CLONE_PATH"
/tmp/post_clone.sh
rm /tmp/post_clone.sh
fi
+2 -2
View File
@@ -13,7 +13,7 @@ Run the [Goose](https://block.github.io/goose/) agent in your workspace to gener
```tf
module "goose" {
source = "registry.coder.com/coder/goose/coder"
version = "2.2.0"
version = "2.2.1"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_goose = true
@@ -79,7 +79,7 @@ resource "coder_agent" "main" {
module "goose" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/goose/coder"
version = "2.2.0"
version = "2.2.1"
agent_id = coder_agent.example.id
folder = "/home/coder"
install_goose = true
+2
View File
@@ -135,6 +135,7 @@ EOT
install_script = file("${path.module}/scripts/install.sh")
start_script = file("${path.module}/scripts/start.sh")
module_dir_name = ".goose-module"
folder = trimsuffix(var.folder, "/")
}
module "agentapi" {
@@ -156,6 +157,7 @@ module "agentapi" {
pre_install_script = var.pre_install_script
post_install_script = var.post_install_script
start_script = local.start_script
folder = local.folder
install_script = <<-EOT
#!/bin/bash
set -o errexit
@@ -10,6 +10,7 @@ tags: [ide, jetbrains, parameter, gateway]
This module adds a JetBrains Gateway Button to open any workspace with a single click.
> [!TIP]
> We recommend using the [Coder Toolbox module](https://registry.coder.com/modules/coder/jetbrains), which offers significant stability and connectivity benefits over Gateway. Reference our [documentation](https://coder.com/docs/user-guides/workspace-access/jetbrains/toolbox) for more information.
JetBrains recommends a minimum of 4 CPU cores and 8GB of RAM.
@@ -19,7 +20,7 @@ Consult the [JetBrains documentation](https://www.jetbrains.com/help/idea/prereq
module "jetbrains_gateway" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains-gateway/coder"
version = "1.2.4"
version = "1.2.5"
agent_id = coder_agent.example.id
folder = "/home/coder/example"
jetbrains_ides = ["CL", "GO", "IU", "PY", "WS"]
@@ -37,7 +38,7 @@ module "jetbrains_gateway" {
module "jetbrains_gateway" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains-gateway/coder"
version = "1.2.4"
version = "1.2.5"
agent_id = coder_agent.example.id
folder = "/home/coder/example"
jetbrains_ides = ["GO", "WS"]
@@ -51,7 +52,7 @@ module "jetbrains_gateway" {
module "jetbrains_gateway" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains-gateway/coder"
version = "1.2.4"
version = "1.2.5"
agent_id = coder_agent.example.id
folder = "/home/coder/example"
jetbrains_ides = ["IU", "PY"]
@@ -66,7 +67,7 @@ module "jetbrains_gateway" {
module "jetbrains_gateway" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains-gateway/coder"
version = "1.2.4"
version = "1.2.5"
agent_id = coder_agent.example.id
folder = "/home/coder/example"
jetbrains_ides = ["IU", "PY"]
@@ -91,7 +92,7 @@ module "jetbrains_gateway" {
module "jetbrains_gateway" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains-gateway/coder"
version = "1.2.4"
version = "1.2.5"
agent_id = coder_agent.example.id
folder = "/home/coder/example"
jetbrains_ides = ["GO", "WS"]
@@ -109,7 +110,7 @@ Due to the highest priority of the `ide_download_link` parameter in the `(jetbra
module "jetbrains_gateway" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/jetbrains-gateway/coder"
version = "1.2.4"
version = "1.2.5"
agent_id = coder_agent.example.id
folder = "/home/coder/example"
jetbrains_ides = ["GO", "WS"]
+1 -1
View File
@@ -14,7 +14,7 @@ Automatically install [KasmVNC](https://kasmweb.com/kasmvnc) in a workspace, and
module "kasmvnc" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/kasmvnc/coder"
version = "1.2.3"
version = "1.2.4"
agent_id = coder_agent.example.id
desktop_environment = "xfce"
subdomain = true
+9 -8
View File
@@ -60,6 +60,9 @@ install_deb() {
sudo apt-get -o DPkg::Lock::Timeout=300 -qq update
fi
echo "Installing required Perl DateTime module..."
DEBIAN_FRONTEND=noninteractive sudo apt-get -o DPkg::Lock::Timeout=300 install --yes -qq --no-install-recommends --no-install-suggests libdatetime-perl
DEBIAN_FRONTEND=noninteractive sudo apt-get -o DPkg::Lock::Timeout=300 install --yes -qq --no-install-recommends --no-install-suggests "$kasmdeb"
rm "$kasmdeb"
}
@@ -233,19 +236,17 @@ get_http_dir() {
# Check the system configuration path
if [[ -e /etc/kasmvnc/kasmvnc.yaml ]]; then
d=($(grep -E "^\s*httpd_directory:.*$" /etc/kasmvnc/kasmvnc.yaml))
# If this grep is successful, it will return:
# httpd_directory: /usr/share/kasmvnc/www
if [[ $${#d[@]} -eq 2 && -d "$${d[1]}" ]]; then
httpd_directory="$${d[1]}"
d=$(grep -E '^\s*httpd_directory:.*$' "/etc/kasmvnc/kasmvnc.yaml" | awk '{print $$2}')
if [[ -n "$d" && -d "$d" ]]; then
httpd_directory=$d
fi
fi
# Check the home directory for overriding values
if [[ -e "$HOME/.vnc/kasmvnc.yaml" ]]; then
d=($(grep -E "^\s*httpd_directory:.*$" "$HOME/.vnc/kasmvnc.yaml"))
if [[ $${#d[@]} -eq 2 && -d "$${d[1]}" ]]; then
httpd_directory="$${d[1]}"
d=$(grep -E '^\s*httpd_directory:.*$' "$HOME/.vnc/kasmvnc.yaml" | awk '{print $$2}')
if [[ -n "$d" && -d "$d" ]]; then
httpd_directory=$d
fi
fi
echo $httpd_directory
+523
View File
@@ -0,0 +1,523 @@
---
display_name: Restic Backup
description: Cloud-backed ephemeral workspaces with automatic backup on stop and restore on start using Restic
icon: ../../../../.icons/restic.svg
verified: false
tags: [backup, restore, cloud, restic, s3, b2]
---
# Restic Backup
Automatic cloud backups for Coder workspaces. Backs up on stop, restores on start.
## Features
- Auto backup/restore on workspace stop/start
- Works with S3, B2, Azure, GCS, SFTP, local storage
- Encrypted and deduplicated
- Workspace-aware tagging for easy browsing
- Configurable retention policies
- Clone backups between workspaces
## Quick Start
```tf
module "restic" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/restic/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
repository = "s3:s3.amazonaws.com/my-workspace-backups"
password = var.restic_password
env = {
AWS_ACCESS_KEY_ID = var.aws_access_key
AWS_SECRET_ACCESS_KEY = var.aws_secret_key
}
}
```
## How It Works
1. Workspace stops → automatic backup to cloud
2. Workspace starts → automatic restore from backup
3. Backups are tagged with `workspace-id`, `workspace-owner`, `workspace-name`
4. Auto-restore uses `workspace-id` to find the correct backup
5. Manually restore any backup using `snapshot_id`
## Storage Backend Configuration
### AWS S3
[Official Restic S3 Documentation](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#amazon-s3)
```tf
module "restic" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/restic/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
repository = "s3:s3.amazonaws.com/my-bucket/workspace-backups"
password = var.restic_password
env = {
AWS_ACCESS_KEY_ID = var.aws_access_key
AWS_SECRET_ACCESS_KEY = var.aws_secret_key
AWS_DEFAULT_REGION = "us-east-1"
}
}
```
### Backblaze B2 (Cost-Effective)
[Official Restic B2 Documentation](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#backblaze-b2)
```tf
module "restic" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/restic/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
repository = "b2:my-bucket:workspace-backups"
password = var.restic_password
env = {
B2_ACCOUNT_ID = var.b2_account_id
B2_ACCOUNT_KEY = var.b2_account_key
}
}
```
### Azure Blob Storage
[Official Restic Azure Documentation](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#microsoft-azure-blob-storage)
```tf
module "restic" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/restic/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
repository = "azure:container-name:/workspace-backups"
password = var.restic_password
env = {
AZURE_ACCOUNT_NAME = var.azure_account_name
AZURE_ACCOUNT_KEY = var.azure_account_key
}
}
```
### Google Cloud Storage
[Official Restic GCS Documentation](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#google-cloud-storage)
```tf
module "restic" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/restic/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
repository = "gs:my-bucket:/workspace-backups"
password = var.restic_password
env = {
GOOGLE_PROJECT_ID = var.gcp_project_id
GOOGLE_APPLICATION_CREDENTIALS = "/path/to/service-account.json"
}
}
```
### MinIO or S3-Compatible Storage
[Official Restic Minio Documentation](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#minio-server) | [S3-Compatible](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#s3-compatible-storage)
```tf
module "restic" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/restic/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
repository = "s3:http://minio.company.com:9000/workspace-backups"
password = var.restic_password
env = {
AWS_ACCESS_KEY_ID = var.minio_access_key
AWS_SECRET_ACCESS_KEY = var.minio_secret_key
}
}
```
### SFTP
[Official Restic SFTP Documentation](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#sftp)
```tf
module "restic" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/restic/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
repository = "sftp:user@backup-server.com:/backups/restic"
password = var.restic_password
# SSH key should be at ~/.ssh/id_rsa
# Or configure custom SSH command:
env = {
RESTIC_SFTP_COMMAND = "ssh user@host -i /path/to/key -s sftp"
}
}
```
### Local Directory (Testing)
[Official Restic Local Documentation](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#local)
```tf
module "restic" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/restic/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
repository = "/backup/restic-repo"
password = var.restic_password
}
```
**Note:** Use persistent storage (Docker volume, PV) for local repositories.
## Advanced Configuration
### Selective Backup Paths
Only backup specific directories:
```tf
module "restic" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/restic/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
repository = "s3:s3.amazonaws.com/backups"
password = var.restic_password
backup_paths = [
"/home/coder/projects",
"/home/coder/.config",
"/home/coder/data",
]
exclude_patterns = [
"**/.git",
"**/node_modules",
"**/__pycache__",
"**/target",
"**/.venv",
"**/tmp",
]
env = {
AWS_ACCESS_KEY_ID = var.aws_access_key
AWS_SECRET_ACCESS_KEY = var.aws_secret_key
}
}
```
### Periodic Backups While Running
Backup every N minutes while workspace is active:
```tf
module "restic" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/restic/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
repository = "b2:workspace-backups"
password = var.restic_password
# Backup every 30 minutes while workspace is running
backup_interval_minutes = 30
env = {
B2_ACCOUNT_ID = var.b2_account_id
B2_ACCOUNT_KEY = var.b2_account_key
}
}
```
### Custom Stop Script
Run cleanup before backup:
```tf
module "restic" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/restic/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
repository = "s3:s3.amazonaws.com/backups"
password = var.restic_password
custom_stop_script = <<-EOF
#!/bin/bash
echo "Cleaning up before backup..."
rm -rf /tmp/*
docker system prune -f
find /home/coder -name "*.log" -delete
EOF
env = {
AWS_ACCESS_KEY_ID = var.aws_access_key
AWS_SECRET_ACCESS_KEY = var.aws_secret_key
}
}
```
### Clone Another Workspace's Backup
Restore from a specific snapshot:
```tf
module "restic" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/restic/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
repository = "s3:s3.amazonaws.com/backups"
password = var.restic_password
# Restore from specific snapshot (find ID using: restic snapshots)
restore_on_start = true
snapshot_id = "abc123def" # The snapshot ID to restore
env = {
AWS_ACCESS_KEY_ID = var.aws_access_key
AWS_SECRET_ACCESS_KEY = var.aws_secret_key
}
}
```
To find snapshot IDs from another workspace:
```bash
# List all snapshots grouped by workspace
restic snapshots --group-by tags
# Or filter by specific workspace
restic snapshots --tag workspace-owner:john --tag workspace-name:dev-workspace
```
### Custom Retention Policies
Control how many backups to keep:
```tf
module "restic" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/restic/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
repository = "s3:s3.amazonaws.com/backups"
password = var.restic_password
# Keep last 10 backups
retention_keep_last = 10
# Keep daily backups for 14 days
retention_keep_daily = 14
# Keep weekly backups for 8 weeks
retention_keep_weekly = 8
# Keep monthly backups for 6 months
retention_keep_monthly = 6
# Apply retention automatically
auto_forget = true
# Don't prune on stop (too slow)
auto_prune = false
env = {
AWS_ACCESS_KEY_ID = var.aws_access_key
AWS_SECRET_ACCESS_KEY = var.aws_secret_key
}
}
```
### Using HCP Vault Secrets
Store credentials securely:
```tf
module "vault_secrets" {
source = "registry.coder.com/coder/hcp-vault-secrets/coder"
version = "1.0.34"
agent_id = coder_agent.main.id
app_name = "workspace-backups"
project_id = var.hcp_project_id
secrets = ["RESTIC_PASSWORD", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"]
}
module "restic" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/restic/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
repository = "s3:s3.amazonaws.com/backups"
password = "" # Will use RESTIC_PASSWORD from vault
depends_on = [module.vault_secrets]
}
```
## Manual Operations
### Trigger Manual Backup
Click the **"Backup Now"** button in the Coder UI, or run from terminal:
```bash
restic-backup --tag manual-backup
```
### List Your Workspace's Backups
```bash
restic snapshots --tag workspace-id:$RESTIC_WORKSPACE_ID
```
Or view all snapshots:
```bash
restic snapshots
```
### List All Workspace Backups in Repository
```bash
restic snapshots --group-by tags
```
This shows snapshots grouped by workspace, making it easy to see all workspace backups in the repository.
### Restore Specific Snapshot
```bash
# List snapshots for this workspace
restic snapshots --tag workspace-id:$RESTIC_WORKSPACE_ID
# Restore to temporary location for inspection
restic restore /tmp/restore < snapshot-id > --target
# Or restore to original location
restic restore / < snapshot-id > --target
```
### Check Repository Health
```bash
restic check
```
### Manual Cleanup
```bash
# Remove old snapshots for this workspace
restic forget --tag workspace-id:$RESTIC_WORKSPACE_ID --keep-last 3
# Reclaim space (removes unreferenced data)
restic prune
```
## Important Considerations
### Stop Backup Limitations
> **Warning**: The `backup_on_stop` feature may not work on all template types if the agent is terminated before backup completes. See [coder/coder#6174](https://github.com/coder/coder/issues/6174) for details.
**Recommendations**:
- Test stop backups with your specific template
- Keep backups fast (use selective paths and exclusions)
- Use `backup_interval_minutes` for important data
- Set `auto_prune = false` for stop backups (prune is slow)
### Repository Organization
**Single Shared Repository** (Recommended):
- All workspaces share one repository
- Backups are tagged with workspace metadata
- Deduplication saves space
- Easy credential management
**Per-Workspace Repositories**:
- Each workspace uses separate repository
- More isolation but more complex
- No cross-workspace restore
### Security
- Repository password encrypts ALL backups
- Use Coder parameters or external secrets for credentials
- Backend credentials should have minimal permissions
- Consider separate repositories for different teams
### Performance Tips
- **Use exclusions**: Skip `.git`, `node_modules`, caches
- **Selective paths**: Only backup what you need
- **Interval backups**: Balance frequency vs performance
- **Retention policies**: Keep low retention to save storage costs
- **Prune manually**: Don't enable `auto_prune` on stop (too slow)
## Troubleshooting
### Backup Fails on Stop
The workspace might be terminating before backup completes. Try:
- Reducing backup size with selective paths
- Using interval backups instead
- Testing with a local repository first
### Restore Blocks Login Too Long
- Reduce restore size with selective backup paths
- Set `start_blocks_login = false` to allow login during restore
- Use faster storage backend
### Repository Not Found
Ensure:
- Repository URL is correct
- Backend credentials are valid
- Network connectivity to storage backend
- Repository has been initialized (`auto_init_repo = true`)
### Permission Denied
Check:
- Backend credentials have write permissions
- Local directory (if used) is writable
- SSH key (for SFTP) is accessible
### Out of Storage Space
Run cleanup:
```bash
restic forget --tag workspace-id:$RESTIC_WORKSPACE_ID --keep-last 2
restic prune
```
## Links
- [Restic Documentation](https://restic.readthedocs.io/)
- [Restic GitHub](https://github.com/restic/restic)
- [Coder Documentation](https://coder.com/docs)
@@ -0,0 +1,75 @@
import { describe, expect, it } from "bun:test";
import {
executeScriptInContainer,
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "~test";
describe("restic", async () => {
await runTerraformInit(import.meta.dir);
testRequiredVariables(import.meta.dir, {
agent_id: "test-agent-id",
repository: "s3:s3.amazonaws.com/test-bucket",
password: "test-password",
});
it("installs restic successfully", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "test-agent",
repository: "/tmp/restic-repo",
password: "test-password",
install_restic: "true",
auto_init_repo: "false",
restore_on_start: "false",
});
const output = await executeScriptInContainer(
state,
"alpine",
"sh",
"apk add --no-cache curl bzip2",
);
if (output.exitCode !== 0) {
console.log("Exit code:", output.exitCode);
console.log("STDOUT:", output.stdout.join("\n"));
console.log("STDERR:", output.stderr.join("\n"));
}
expect(output.exitCode).toBe(0);
const stdout = output.stdout.join("\n");
expect(stdout).toContain("Restic Backup Module Setup");
expect(stdout).toContain("Installing Restic...");
expect(stdout).toContain("Detected OS: linux");
expect(stdout).toContain("Architecture:");
expect(stdout).toContain("Fetching latest version");
expect(stdout).toContain("Version:");
expect(stdout).toContain("Downloading Restic");
expect(stdout).toContain("Restic installed:");
expect(stdout).toContain("Restic verified:");
expect(stdout).toContain("restic");
expect(stdout).toContain("Restic setup complete");
});
it("creates backup helper script in workspace", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "test-agent",
repository: "/tmp/restic-repo",
password: "test-password",
install_restic: "false",
auto_init_repo: "false",
restore_on_start: "false",
});
const output = await executeScriptInContainer(state, "alpine");
const stdout = output.stdout.join("\n");
expect(stdout).toContain("Installing backup helper script");
expect(stdout).toContain("Backup helper installed:");
expect(stdout).toContain("/restic-backup");
expect(stdout).toContain("Backup helper verified as executable");
});
});
+271
View File
@@ -0,0 +1,271 @@
terraform {
required_version = ">= 1.0"
required_providers {
coder = {
source = "coder/coder"
version = ">= 0.12"
}
}
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
}
variable "repository" {
type = string
description = "Restic repository location (e.g., 's3:s3.amazonaws.com/bucket', 'b2:bucket-name', '/local/path')."
}
variable "password" {
type = string
description = "Password for encrypting the Restic repository. Keep this secure!"
sensitive = true
}
variable "install_restic" {
type = bool
description = "Whether to install Restic binary."
default = true
}
variable "restic_version" {
type = string
description = "Version of Restic to install (e.g., '0.16.4' or 'latest')."
default = "latest"
}
variable "backup_paths" {
type = list(string)
description = "List of paths to backup. Can be absolute or relative to 'directory'."
default = ["/home/coder"]
}
variable "exclude_patterns" {
type = list(string)
description = "Patterns to exclude from backup (e.g., ['**/.git', '**/node_modules'])."
default = []
}
variable "backup_tags" {
type = list(string)
description = "Additional tags to apply to all snapshots."
default = []
}
variable "directory" {
type = string
description = "Working directory for backup operations."
default = "~"
}
variable "backup_on_stop" {
type = bool
description = "Whether to automatically backup when workspace stops."
default = true
}
variable "backup_interval_minutes" {
type = number
description = "Backup every N minutes while workspace is running (0 = disabled)."
default = 0
}
variable "restore_on_start" {
type = bool
description = "Whether to restore from backup when workspace starts."
default = true
}
variable "snapshot_id" {
type = string
description = "Specific snapshot ID to restore. If empty and restore_on_start is true, restores latest backup of this workspace. If set, restores that specific snapshot (useful for cloning workspaces)."
default = ""
}
variable "restore_target" {
type = string
description = "Target directory for restore ('/' restores to original paths)."
default = "/"
}
variable "start_blocks_login" {
type = bool
description = "Whether to block login until restore completes."
default = true
}
variable "custom_stop_script" {
type = string
description = "Custom script to run before stop backup."
default = ""
}
variable "retention_keep_last" {
type = number
description = "Keep last N snapshots per workspace."
default = 10
}
variable "retention_keep_daily" {
type = number
description = "Keep daily snapshots for N days."
default = 14
}
variable "retention_keep_weekly" {
type = number
description = "Keep weekly snapshots for N weeks."
default = 8
}
variable "retention_keep_monthly" {
type = number
description = "Keep monthly snapshots for N months."
default = 6
}
variable "auto_forget" {
type = bool
description = "Apply retention policies automatically after backup."
default = false
}
variable "auto_prune" {
type = bool
description = "Run prune after forget to reclaim space (slower but frees storage)."
default = false
}
variable "auto_init_repo" {
type = bool
description = "Automatically initialize repository if it doesn't exist."
default = true
}
variable "env" {
type = map(string)
description = "Environment variables for backend configuration (e.g., AWS_ACCESS_KEY_ID, B2_ACCOUNT_KEY). See README for backend-specific examples."
default = {}
sensitive = true
}
variable "icon" {
type = string
description = "Icon to use for Restic apps."
default = "/icon/restic.svg"
}
variable "order" {
type = number
description = "Order of apps in UI."
default = null
}
variable "group" {
type = string
description = "Group name for apps."
default = null
}
resource "coder_env" "restic_repository" {
agent_id = var.agent_id
name = "RESTIC_REPOSITORY"
value = var.repository
}
resource "coder_env" "restic_password" {
agent_id = var.agent_id
name = "RESTIC_PASSWORD"
value = var.password
}
resource "coder_env" "backend_env" {
for_each = nonsensitive(var.env)
agent_id = var.agent_id
name = each.key
value = each.value
}
resource "coder_env" "workspace_owner" {
agent_id = var.agent_id
name = "RESTIC_WORKSPACE_OWNER"
value = data.coder_workspace_owner.me.name
}
resource "coder_env" "workspace_name" {
agent_id = var.agent_id
name = "RESTIC_WORKSPACE_NAME"
value = data.coder_workspace.me.name
}
resource "coder_env" "workspace_id" {
agent_id = var.agent_id
name = "RESTIC_WORKSPACE_ID"
value = data.coder_workspace.me.id
}
resource "coder_script" "install_and_restore" {
agent_id = var.agent_id
display_name = "Restic Setup"
icon = var.icon
run_on_start = true
start_blocks_login = var.restore_on_start && var.start_blocks_login
script = templatefile("${path.module}/scripts/run.sh", {
INSTALL_RESTIC = var.install_restic
RESTIC_VERSION = var.restic_version
AUTO_INIT = var.auto_init_repo
RESTORE_ON_START = var.restore_on_start
SNAPSHOT_ID = var.snapshot_id
RESTORE_TARGET = var.restore_target
BACKUP_INTERVAL = var.backup_interval_minutes
BACKUP_PATHS = jsonencode(var.backup_paths)
EXCLUDE_PATTERNS = jsonencode(var.exclude_patterns)
BACKUP_TAGS = jsonencode(var.backup_tags)
DIRECTORY = var.directory
RETENTION_LAST = var.retention_keep_last
RETENTION_DAILY = var.retention_keep_daily
RETENTION_WEEKLY = var.retention_keep_weekly
RETENTION_MONTHLY = var.retention_keep_monthly
AUTO_FORGET = var.auto_forget
AUTO_PRUNE = var.auto_prune
BACKUP_SCRIPT_B64 = base64encode(file("${path.module}/scripts/backup.sh"))
})
}
resource "coder_script" "stop_backup" {
count = var.backup_on_stop ? 1 : 0
agent_id = var.agent_id
display_name = "Restic Backup"
icon = var.icon
run_on_stop = true
start_blocks_login = false
script = <<-EOT
#!/usr/bin/env bash
set -euo pipefail
${var.custom_stop_script}
"$CODER_SCRIPT_BIN_DIR/restic-backup" --tag "stop-backup"
EOT
}
resource "coder_app" "restic_backup" {
agent_id = var.agent_id
slug = "restic-backup"
display_name = "Backup Now"
icon = var.icon
order = var.order
group = var.group
command = "$CODER_SCRIPT_BIN_DIR/restic-backup --tag manual-backup"
}
@@ -0,0 +1,333 @@
run "required_variables" {
command = plan
variables {
agent_id = "test-agent"
repository = "s3:s3.amazonaws.com/test-bucket"
password = "test-password"
}
}
run "stop_backup_script_created_when_enabled" {
command = plan
variables {
agent_id = "test-agent"
repository = "/tmp/restic-repo"
password = "test-password"
backup_on_stop = true
}
assert {
condition = coder_script.stop_backup[0].run_on_stop == true
error_message = "Stop backup script should have run_on_stop enabled"
}
assert {
condition = coder_script.stop_backup[0].agent_id == "test-agent"
error_message = "Stop backup script should use correct agent_id"
}
}
run "stop_backup_script_not_created_when_disabled" {
command = plan
variables {
agent_id = "test-agent"
repository = "/tmp/restic-repo"
password = "test-password"
backup_on_stop = false
}
assert {
condition = length(coder_script.stop_backup) == 0
error_message = "Stop backup script should not be created when backup_on_stop is false"
}
}
run "restore_blocks_login_by_default" {
command = plan
variables {
agent_id = "test-agent"
repository = "/tmp/restic-repo"
password = "test-password"
restore_on_start = true
}
assert {
condition = coder_script.install_and_restore.start_blocks_login == true
error_message = "Install script should block login when restore_on_start and start_blocks_login are true"
}
}
run "restore_does_not_block_login_when_disabled" {
command = plan
variables {
agent_id = "test-agent"
repository = "/tmp/restic-repo"
password = "test-password"
restore_on_start = true
start_blocks_login = false
}
assert {
condition = coder_script.install_and_restore.start_blocks_login == false
error_message = "Install script should not block login when start_blocks_login is false"
}
}
run "workspace_metadata_env_vars_created" {
command = plan
variables {
agent_id = "test-agent"
repository = "/tmp/restic-repo"
password = "test-password"
}
assert {
condition = coder_env.workspace_owner.name == "RESTIC_WORKSPACE_OWNER"
error_message = "Workspace owner env var should be RESTIC_WORKSPACE_OWNER"
}
assert {
condition = coder_env.workspace_name.name == "RESTIC_WORKSPACE_NAME"
error_message = "Workspace name env var should be RESTIC_WORKSPACE_NAME"
}
assert {
condition = coder_env.workspace_id.name == "RESTIC_WORKSPACE_ID"
error_message = "Workspace ID env var should be RESTIC_WORKSPACE_ID"
}
}
run "core_env_vars_created" {
command = plan
variables {
agent_id = "test-agent"
repository = "s3:s3.amazonaws.com/bucket"
password = "secure-password"
}
assert {
condition = coder_env.restic_repository.name == "RESTIC_REPOSITORY"
error_message = "Repository env var should be RESTIC_REPOSITORY"
}
assert {
condition = coder_env.restic_repository.value == "s3:s3.amazonaws.com/bucket"
error_message = "Repository env var should match input"
}
assert {
condition = coder_env.restic_password.name == "RESTIC_PASSWORD"
error_message = "Password env var should be RESTIC_PASSWORD"
}
}
run "safe_retention_defaults" {
command = plan
variables {
agent_id = "test-agent"
repository = "/tmp/restic-repo"
password = "test-password"
}
# Verify auto_forget is false by default (safe)
assert {
condition = var.auto_forget == false
error_message = "auto_forget should be false by default for safety"
}
# Verify reasonable retention defaults
assert {
condition = var.retention_keep_last == 10
error_message = "Default retention_keep_last should be 10"
}
assert {
condition = var.retention_keep_daily == 14
error_message = "Default retention_keep_daily should be 14"
}
}
run "manual_backup_app_created" {
command = plan
variables {
agent_id = "test-agent"
repository = "/tmp/restic-repo"
password = "test-password"
}
assert {
condition = coder_app.restic_backup.slug == "restic-backup"
error_message = "Backup app should have slug restic-backup"
}
assert {
condition = coder_app.restic_backup.display_name == "Backup Now"
error_message = "Backup app should display 'Backup Now'"
}
assert {
condition = can(regex("restic-backup", coder_app.restic_backup.command))
error_message = "Backup app command should call restic-backup helper"
}
}
run "install_restic_enabled_in_script" {
command = plan
variables {
agent_id = "test-agent"
repository = "/tmp/restic-repo"
password = "test-password"
install_restic = true
}
assert {
condition = can(regex("INSTALL_RESTIC=\"true\"", coder_script.install_and_restore.script))
error_message = "Script should have INSTALL_RESTIC set to true"
}
}
run "install_restic_disabled_in_script" {
command = plan
variables {
agent_id = "test-agent"
repository = "/tmp/restic-repo"
password = "test-password"
install_restic = false
}
assert {
condition = can(regex("INSTALL_RESTIC=\"false\"", coder_script.install_and_restore.script))
error_message = "Script should have INSTALL_RESTIC set to false"
}
}
run "auto_init_repo_configuration" {
command = plan
variables {
agent_id = "test-agent"
repository = "/tmp/restic-repo"
password = "test-password"
auto_init_repo = false
}
assert {
condition = can(regex("AUTO_INIT=\"false\"", coder_script.install_and_restore.script))
error_message = "Script should have AUTO_INIT set to false"
}
}
run "restore_on_start_configuration" {
command = plan
variables {
agent_id = "test-agent"
repository = "/tmp/restic-repo"
password = "test-password"
restore_on_start = true
snapshot_id = "abc123"
}
assert {
condition = can(regex("RESTORE_ON_START=\"true\"", coder_script.install_and_restore.script))
error_message = "Script should have RESTORE_ON_START set to true"
}
assert {
condition = can(regex("SNAPSHOT_ID=\"abc123\"", coder_script.install_and_restore.script))
error_message = "Script should have SNAPSHOT_ID set to abc123"
}
}
run "interval_backup_configuration" {
command = plan
variables {
agent_id = "test-agent"
repository = "/tmp/restic-repo"
password = "test-password"
backup_interval_minutes = 30
}
assert {
condition = can(regex("BACKUP_INTERVAL=\"30\"", coder_script.install_and_restore.script))
error_message = "Script should have BACKUP_INTERVAL set to 30"
}
}
run "interval_backup_disabled_by_default" {
command = plan
variables {
agent_id = "test-agent"
repository = "/tmp/restic-repo"
password = "test-password"
}
assert {
condition = can(regex("BACKUP_INTERVAL=\"0\"", coder_script.install_and_restore.script))
error_message = "Script should have BACKUP_INTERVAL set to 0 by default"
}
}
run "backup_paths_and_exclusions_configuration" {
command = plan
variables {
agent_id = "test-agent"
repository = "/tmp/restic-repo"
password = "test-password"
backup_paths = ["/home/coder", "/workspace"]
exclude_patterns = ["*.log", "node_modules"]
backup_tags = ["production", "daily"]
}
assert {
condition = can(regex("/home/coder", coder_script.install_and_restore.script))
error_message = "Script should contain backup path /home/coder"
}
assert {
condition = can(regex("/workspace", coder_script.install_and_restore.script))
error_message = "Script should contain backup path /workspace"
}
assert {
condition = can(regex("\\*.log", coder_script.install_and_restore.script))
error_message = "Script should contain exclude pattern *.log"
}
assert {
condition = can(regex("production", coder_script.install_and_restore.script))
error_message = "Script should contain backup tag production"
}
}
run "custom_stop_script_included" {
command = plan
variables {
agent_id = "test-agent"
repository = "/tmp/restic-repo"
password = "test-password"
backup_on_stop = true
custom_stop_script = "echo 'Pre-backup cleanup'"
}
assert {
condition = can(regex("echo 'Pre-backup cleanup'", coder_script.stop_backup[0].script))
error_message = "Stop script should contain custom stop script"
}
}
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
set -euo pipefail
CONF_FILE="$CODER_SCRIPT_DATA_DIR/restic-backup.conf"
if [ -f "$CONF_FILE" ]; then
# shellcheck source=/dev/null
source "$CONF_FILE"
else
echo "Error: Configuration file not found: $CONF_FILE" >&2
exit 1
fi
EXTRA_TAGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--tag)
EXTRA_TAGS+=("$2")
shift 2
;;
*)
echo "Unknown argument: $1" >&2
echo "Usage: restic-backup [--tag TAG]" >&2
exit 1
;;
esac
done
echo "--------------------------------"
echo "Restic Backup"
echo "--------------------------------"
DIRECTORY="${DIRECTORY/#\~/$HOME}"
PATHS=$(echo "$BACKUP_PATHS" | python3 -c "import json, sys; print(' '.join(json.load(sys.stdin)))" 2> /dev/null || echo ".")
EXCLUDES=$(echo "$EXCLUDE_PATTERNS" | python3 -c "import json, sys; [print(f'--exclude={p}') for p in json.load(sys.stdin)]" 2> /dev/null || echo "")
TAGS=$(echo "$BACKUP_TAGS" | python3 -c "import json, sys; [print(f'--tag={t}') for t in json.load(sys.stdin)]" 2> /dev/null || echo "")
TAG_ARGS=(
"--tag=workspace-id:$RESTIC_WORKSPACE_ID"
"--tag=workspace-owner:$RESTIC_WORKSPACE_OWNER"
"--tag=workspace-name:$RESTIC_WORKSPACE_NAME"
)
if [ -n "$TAGS" ]; then
while IFS= read -r tag; do
[ -n "$tag" ] && TAG_ARGS+=("$tag")
done <<< "$TAGS"
fi
for tag in "${EXTRA_TAGS[@]}"; do
TAG_ARGS+=("--tag=$tag")
done
EXCLUDE_ARGS=()
if [ -n "$EXCLUDES" ]; then
while IFS= read -r exclude; do
[ -n "$exclude" ] && EXCLUDE_ARGS+=("$exclude")
done <<< "$EXCLUDES"
fi
cd "$DIRECTORY" || {
echo "Error: Failed to change to directory: $DIRECTORY" >&2
exit 1
}
echo "Working directory: $(pwd)"
echo "Backup paths: $PATHS"
echo "Tags: ${TAG_ARGS[*]}"
[ ${#EXCLUDE_ARGS[@]} -gt 0 ] && echo "Exclusions: ${EXCLUDE_ARGS[*]}"
echo "Starting backup..."
# shellcheck disable=SC2086
if restic backup $PATHS "${TAG_ARGS[@]}" "${EXCLUDE_ARGS[@]}"; then
echo "Backup completed successfully"
else
echo "Error: Backup failed" >&2
exit 1
fi
if [ "$AUTO_FORGET" = "true" ]; then
echo "Applying retention policies..."
FORGET_ARGS=(
"--tag=workspace-id:$RESTIC_WORKSPACE_ID"
"--keep-last=$RETENTION_LAST"
)
[ "$RETENTION_DAILY" -gt 0 ] && FORGET_ARGS+=("--keep-daily=$RETENTION_DAILY")
[ "$RETENTION_WEEKLY" -gt 0 ] && FORGET_ARGS+=("--keep-weekly=$RETENTION_WEEKLY")
[ "$RETENTION_MONTHLY" -gt 0 ] && FORGET_ARGS+=("--keep-monthly=$RETENTION_MONTHLY")
if [ "$AUTO_PRUNE" = "true" ]; then
FORGET_ARGS+=("--prune")
echo "Pruning unreferenced data..."
fi
if restic forget "${FORGET_ARGS[@]}"; then
echo "Retention policies applied"
else
echo "Warning: Failed to apply retention policies" >&2
fi
fi
echo "Backup process complete"
@@ -0,0 +1,296 @@
#!/usr/bin/env bash
set -euo pipefail
: $${CODER_SCRIPT_BIN_DIR:=$HOME/.local/bin}
: $${CODER_SCRIPT_DATA_DIR:=$HOME/.local/share/coder}
mkdir -p "$CODER_SCRIPT_BIN_DIR"
mkdir -p "$CODER_SCRIPT_DATA_DIR"
export PATH="$HOME/.local/bin:$PATH"
INSTALL_RESTIC="${INSTALL_RESTIC}"
RESTIC_VERSION="${RESTIC_VERSION}"
AUTO_INIT="${AUTO_INIT}"
RESTORE_ON_START="${RESTORE_ON_START}"
SNAPSHOT_ID="${SNAPSHOT_ID}"
RESTORE_TARGET="${RESTORE_TARGET}"
BACKUP_INTERVAL="${BACKUP_INTERVAL}"
BACKUP_PATHS='${BACKUP_PATHS}'
EXCLUDE_PATTERNS='${EXCLUDE_PATTERNS}'
BACKUP_TAGS='${BACKUP_TAGS}'
DIRECTORY="${DIRECTORY}"
RETENTION_LAST="${RETENTION_LAST}"
RETENTION_DAILY="${RETENTION_DAILY}"
RETENTION_WEEKLY="${RETENTION_WEEKLY}"
RETENTION_MONTHLY="${RETENTION_MONTHLY}"
AUTO_FORGET="${AUTO_FORGET}"
AUTO_PRUNE="${AUTO_PRUNE}"
BACKUP_SCRIPT_B64='${BACKUP_SCRIPT_B64}'
echo "--------------------------------"
echo "Restic Backup Module Setup"
echo "--------------------------------"
detect_os_arch() {
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m)
case "$ARCH" in
x86_64)
ARCH="amd64"
;;
aarch64 | arm64)
ARCH="arm64"
;;
armv7l)
ARCH="arm"
;;
*)
echo "Unsupported architecture: $ARCH"
exit 1
;;
esac
case "$OS" in
linux | darwin) ;;
*)
echo "Unsupported OS: $OS"
exit 1
;;
esac
echo "Detected OS: $OS, Architecture: $ARCH"
}
install_restic() {
if [ "$INSTALL_RESTIC" != "true" ]; then
echo "Skipping Restic installation (install_restic=false)"
return
fi
if command -v restic > /dev/null 2>&1; then
INSTALLED_VERSION=$(restic version | head -n1 | awk '{print $2}')
echo "Restic already installed: $INSTALLED_VERSION"
if [ "$RESTIC_VERSION" != "latest" ] && [ "$INSTALLED_VERSION" != "$RESTIC_VERSION" ]; then
echo "Warning: Version mismatch (installed: $INSTALLED_VERSION, requested: $RESTIC_VERSION)"
fi
return
fi
echo "Installing Restic..."
detect_os_arch
if [ "$RESTIC_VERSION" = "latest" ]; then
echo "Fetching latest version..."
LATEST_VERSION=$(curl -fsSL https://api.github.com/repos/restic/restic/releases/latest | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\1/')
if [ -z "$LATEST_VERSION" ]; then
echo "Error: Failed to fetch latest version"
exit 1
fi
echo "Version: $LATEST_VERSION"
DOWNLOAD_URL="https://github.com/restic/restic/releases/download/v$${LATEST_VERSION}/restic_$${LATEST_VERSION}_$${OS}_$${ARCH}.bz2"
else
DOWNLOAD_URL="https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/restic_${RESTIC_VERSION}_$${OS}_$${ARCH}.bz2"
fi
echo "Downloading Restic..."
mkdir -p "$HOME/.local/bin"
TMP_FILE=$(mktemp)
if curl -fsSL "$DOWNLOAD_URL" -o "$TMP_FILE"; then
bunzip2 -c "$TMP_FILE" > "$HOME/.local/bin/restic"
chmod +x "$HOME/.local/bin/restic"
rm "$TMP_FILE"
echo "Restic installed: $($HOME/.local/bin/restic version)"
else
echo "Error: Download failed"
rm -f "$TMP_FILE"
exit 1
fi
}
verify_installation() {
if ! command -v restic > /dev/null 2>&1; then
echo "Error: restic command not found in PATH"
echo "PATH: $PATH"
if [ "$INSTALL_RESTIC" = "true" ]; then
exit 1
else
echo "Warning: restic not found but install_restic=false, continuing anyway"
return
fi
fi
echo "Restic verified: $(restic version | head -n1)"
}
init_repository() {
if [ "$AUTO_INIT" != "true" ]; then
echo "Skipping repository initialization (auto_init_repo=false)"
return
fi
echo "Checking repository..."
if restic snapshots > /dev/null 2>&1; then
echo "Repository already initialized"
return
fi
echo "Initializing repository..."
if restic init; then
echo "Repository initialized"
else
echo "Error: Failed to initialize repository"
exit 1
fi
}
install_backup_helper() {
echo "Installing backup helper script..."
HELPER_SCRIPT="$CODER_SCRIPT_BIN_DIR/restic-backup"
echo -n "$BACKUP_SCRIPT_B64" | base64 -d > "$HELPER_SCRIPT"
chmod +x "$HELPER_SCRIPT"
cat > "$CODER_SCRIPT_DATA_DIR/restic-backup.conf" << EOF
BACKUP_PATHS='$BACKUP_PATHS'
EXCLUDE_PATTERNS='$EXCLUDE_PATTERNS'
BACKUP_TAGS='$BACKUP_TAGS'
DIRECTORY='$DIRECTORY'
RETENTION_LAST='$RETENTION_LAST'
RETENTION_DAILY='$RETENTION_DAILY'
RETENTION_WEEKLY='$RETENTION_WEEKLY'
RETENTION_MONTHLY='$RETENTION_MONTHLY'
AUTO_FORGET='$AUTO_FORGET'
AUTO_PRUNE='$AUTO_PRUNE'
EOF
if [ ! -x "$HELPER_SCRIPT" ]; then
echo "Error: Backup helper is not executable"
exit 1
fi
echo "Backup helper installed: $HELPER_SCRIPT"
echo "Backup helper verified as executable"
}
find_latest_snapshot() {
local TAG_FILTER="$1"
SNAPSHOTS_JSON=$(restic snapshots --tag "$TAG_FILTER" --json 2> /dev/null || echo "[]")
LATEST_SNAPSHOT=$(echo "$SNAPSHOTS_JSON" | python3 -c "
import json, sys
snapshots = json.load(sys.stdin)
if snapshots:
latest = max(snapshots, key=lambda s: s['time'])
print(latest['short_id'])
else:
print('')
" 2> /dev/null || echo "")
echo "$LATEST_SNAPSHOT"
}
restore_on_start() {
if [ "$RESTORE_ON_START" != "true" ]; then
echo "Skipping restore (restore_on_start=false)"
return
fi
echo "--------------------------------"
echo "Restore Configuration"
echo "--------------------------------"
SNAPSHOT_TO_RESTORE=""
if [ -n "$SNAPSHOT_ID" ]; then
echo "Restoring specific snapshot: $SNAPSHOT_ID"
SNAPSHOT_TO_RESTORE="$SNAPSHOT_ID"
else
echo "Finding latest backup for this workspace..."
SNAPSHOT_TO_RESTORE=$(find_latest_snapshot "workspace-id:$RESTIC_WORKSPACE_ID")
if [ -z "$SNAPSHOT_TO_RESTORE" ]; then
echo "No previous backup found"
echo "Starting with fresh workspace"
return
fi
echo "Found snapshot: $SNAPSHOT_TO_RESTORE"
fi
echo "Restoring to $RESTORE_TARGET..."
if restic restore "$SNAPSHOT_TO_RESTORE" --target "$RESTORE_TARGET"; then
echo "Restore completed successfully"
else
echo "Error: Restore failed"
exit 1
fi
}
setup_interval_backup() {
if [ "$BACKUP_INTERVAL" -eq 0 ]; then
return
fi
echo "Setting up interval backup (every $BACKUP_INTERVAL minutes)..."
cat > "$CODER_SCRIPT_DATA_DIR/interval-backup.sh" << 'EOFSCRIPT'
#!/usr/bin/env bash
set -euo pipefail
INTERVAL_MINUTES="$1"
INTERVAL_SECONDS=$((INTERVAL_MINUTES * 60))
echo "Starting interval backup loop (every $INTERVAL_MINUTES minutes)"
while true; do
sleep "$INTERVAL_SECONDS"
echo "Running scheduled backup..."
if "$CODER_SCRIPT_BIN_DIR/restic-backup" --tag "interval-backup"; then
echo "Scheduled backup completed"
else
echo "Scheduled backup failed"
fi
done
EOFSCRIPT
chmod +x "$CODER_SCRIPT_DATA_DIR/interval-backup.sh"
nohup "$CODER_SCRIPT_DATA_DIR/interval-backup.sh" "$BACKUP_INTERVAL" \
>> "$CODER_SCRIPT_DATA_DIR/interval-backup.log" 2>&1 &
echo "Interval backup started in background (PID: $!)"
}
main() {
install_restic
verify_installation
init_repository
install_backup_helper
restore_on_start
setup_interval_backup
echo "--------------------------------"
echo "Restic setup complete"
echo "--------------------------------"
echo "Available commands:"
echo " restic-backup - Run manual backup"
echo " restic snapshots - List all snapshots"
echo " restic restore <id> - Restore specific snapshot"
echo ""
echo "Repository: $${RESTIC_REPOSITORY:-not set}"
}
main
@@ -426,15 +426,14 @@ module "code-server" {
# 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
agent_name = "main"
order = 1
agent_id = coder_agent.main.id
order = 1
}
# See https://registry.coder.com/modules/coder/jetbrains
module "jetbrains" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/modules/coder/jetbrains/coder"
source = "registry.coder.com/coder/jetbrains/coder"
version = "~> 1.0"
agent_id = coder_agent.main.id
agent_name = "main"
+1
View File
@@ -19,4 +19,5 @@ participating in LFX CNCF programs, and helping the developer community grow.
## Modules
- **aws-ami-snapshot**: Create and manage AMI snapshots for Coder workspaces with restore capabilities
- [nexus-repository](./modules/nexus-repository/) - Configure package managers to use Sonatype Nexus Repository
- [auto-start-dev-server](modules/auto-start-dev-server/README.md) - Automatically detect and start development servers for various project types
@@ -1,7 +1,7 @@
---
display_name: Auto-Start Development Servers
display_name: Auto-Start Dev Servers
description: Automatically detect and start development servers for various project types
icon: ../../../../.icons/server.svg
icon: ../../../../.icons/auto-dev-server.svg
verified: false
tags: [development, automation, servers]
---
@@ -13,7 +13,7 @@ Automatically detect and start development servers for various project types whe
```tf
module "auto_start_dev_servers" {
source = "registry.coder.com/mavrickrishi/auto-start-dev-server/coder"
version = "1.0.0"
version = "1.0.1"
agent_id = coder_agent.main.id
}
```
@@ -48,20 +48,20 @@ module "auto_start_dev_servers" {
### Basic Usage
```hcl
```tf
module "auto_start" {
source = "./modules/auto-start-dev-server"
version = "1.0.0"
version = "1.0.1"
agent_id = coder_agent.main.id
}
```
### Advanced Usage
```hcl
```tf
module "auto_start_dev_servers" {
source = "./modules/auto-start-dev-server"
version = "1.0.0"
version = "1.0.1"
agent_id = coder_agent.main.id
# Optional: Configure which project types to detect
@@ -70,10 +70,10 @@ module "auto_start_dev_servers" {
enable_django = true
enable_flask = true
enable_spring_boot = true
enable_go = true
enable_php = true
enable_rust = true
enable_dotnet = true
enable_go = true
enable_php = true
enable_rust = true
enable_dotnet = true
# Optional: Enable devcontainer.json integration
enable_devcontainer = true
@@ -97,10 +97,10 @@ module "auto_start_dev_servers" {
### Disable Preview App
```hcl
```tf
module "auto_start" {
source = "./modules/auto-start-dev-server"
version = "1.0.0"
version = "1.0.1"
agent_id = coder_agent.main.id
# Disable automatic preview app creation
@@ -110,10 +110,10 @@ module "auto_start" {
### Selective Project Types
```hcl
```tf
module "auto_start" {
source = "./modules/auto-start-dev-server"
version = "1.0.0"
version = "1.0.1"
agent_id = coder_agent.main.id
# Only enable web development projects
@@ -124,25 +124,25 @@ module "auto_start" {
# Disable other project types
enable_spring_boot = false
enable_go = false
enable_php = false
enable_rust = false
enable_dotnet = false
enable_go = false
enable_php = false
enable_rust = false
enable_dotnet = false
}
```
### Deep Workspace Scanning
```hcl
```tf
module "auto_start" {
source = "./modules/auto-start-dev-server"
version = "1.0.0"
version = "1.0.1"
agent_id = coder_agent.main.id
workspace_directory = "/workspaces"
scan_depth = 3
startup_delay = 5
log_path = "/var/log/dev-servers.log"
scan_depth = 3
startup_delay = 5
log_path = "/var/log/dev-servers.log"
}
```
@@ -131,7 +131,7 @@ locals {
resource "coder_script" "auto_start_dev_server" {
agent_id = var.agent_id
display_name = var.display_name
icon = "/icon/server.svg"
icon = "/icon/auto-dev-server.svg"
script = templatefile("${path.module}/run.sh", {
WORKSPACE_DIR = var.workspace_directory
ENABLE_NPM = coalesce(var.enable_npm, var.project_detection)
@@ -158,7 +158,7 @@ resource "coder_app" "preview" {
slug = "dev-preview"
display_name = "Live Preview"
url = "http://localhost:${local.detected_port}"
icon = "/icon/globe.svg"
icon = "/icon/auto-dev-server.svg"
subdomain = true
share = "owner"
}
@@ -0,0 +1,149 @@
---
display_name: Nexus Repository
description: Configure package managers to use Sonatype Nexus Repository for Maven, npm, PyPI, and Docker registries.
icon: ../../../../.icons/nexus-repository.svg
verified: false
tags: [integration, nexus-repository, maven, npm, pypi, docker]
---
# Sonatype Nexus Repository
Configure package managers (Maven, npm, Go, PyPI, Docker) to use [Sonatype Nexus Repository](https://help.sonatype.com/en/sonatype-nexus-repository.html) with API token authentication. This module provides secure credential handling, multiple repository support per package manager, and flexible username configuration.
```tf
module "nexus_repository" {
source = "registry.coder.com/mavrickrishi/nexus-repository/coder"
version = "1.0.1"
agent_id = coder_agent.example.id
nexus_url = "https://nexus.example.com"
nexus_password = var.nexus_api_token
package_managers = {
maven = ["maven-public", "maven-releases"]
npm = ["npm-public", "@scoped:npm-private"]
go = ["go-public", "go-private"]
pypi = ["pypi-public", "pypi-private"]
docker = ["docker-public", "docker-private"]
}
}
```
## Requirements
- Nexus Repository Manager 3.x
- Valid API token or user credentials
- Package managers installed on the workspace (Maven, npm, Go, pip, Docker as needed)
> [!NOTE]
> This module configures package managers but does not install them. You need to handle the installation of Maven, npm, Go, Python pip, and Docker yourself.
## Examples
### Configure Maven to use Nexus repositories
```tf
module "nexus_repository" {
source = "registry.coder.com/mavrickrishi/nexus-repository/coder"
version = "1.0.1"
agent_id = coder_agent.example.id
nexus_url = "https://nexus.example.com"
nexus_password = var.nexus_api_token
package_managers = {
maven = ["maven-public", "maven-releases", "maven-snapshots"]
}
}
```
### Configure npm with scoped packages
```tf
module "nexus_repository" {
source = "registry.coder.com/mavrickrishi/nexus-repository/coder"
version = "1.0.1"
agent_id = coder_agent.example.id
nexus_url = "https://nexus.example.com"
nexus_password = var.nexus_api_token
package_managers = {
npm = ["npm-public", "@mycompany:npm-private"]
}
}
```
### Configure Go module proxy
```tf
module "nexus_repository" {
source = "registry.coder.com/mavrickrishi/nexus-repository/coder"
version = "1.0.1"
agent_id = coder_agent.example.id
nexus_url = "https://nexus.example.com"
nexus_password = var.nexus_api_token
package_managers = {
go = ["go-public", "go-private"]
}
}
```
### Configure Python PyPI repositories
```tf
module "nexus_repository" {
source = "registry.coder.com/mavrickrishi/nexus-repository/coder"
version = "1.0.1"
agent_id = coder_agent.example.id
nexus_url = "https://nexus.example.com"
nexus_password = var.nexus_api_token
package_managers = {
pypi = ["pypi-public", "pypi-private"]
}
}
```
### Configure Docker registries
```tf
module "nexus_repository" {
source = "registry.coder.com/mavrickrishi/nexus-repository/coder"
version = "1.0.1"
agent_id = coder_agent.example.id
nexus_url = "https://nexus.example.com"
nexus_password = var.nexus_api_token
package_managers = {
docker = ["docker-public", "docker-private"]
}
}
```
### Use custom username
```tf
module "nexus_repository" {
source = "registry.coder.com/mavrickrishi/nexus-repository/coder"
version = "1.0.1"
agent_id = coder_agent.example.id
nexus_url = "https://nexus.example.com"
nexus_username = "custom-user"
nexus_password = var.nexus_api_token
package_managers = {
maven = ["maven-public"]
}
}
```
### Complete configuration for all package managers
```tf
module "nexus_repository" {
source = "registry.coder.com/mavrickrishi/nexus-repository/coder"
version = "1.0.1"
agent_id = coder_agent.example.id
nexus_url = "https://nexus.example.com"
nexus_password = var.nexus_api_token
package_managers = {
maven = ["maven-public", "maven-releases"]
npm = ["npm-public", "@company:npm-private"]
go = ["go-public", "go-private"]
pypi = ["pypi-public", "pypi-private"]
docker = ["docker-public", "docker-private"]
}
}
```
@@ -0,0 +1,147 @@
import { describe, expect, it } from "bun:test";
import {
executeScriptInContainer,
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "~test";
describe("nexus-repository", async () => {
await runTerraformInit(import.meta.dir);
testRequiredVariables(import.meta.dir, {
agent_id: "test-agent",
nexus_url: "https://nexus.example.com",
nexus_password: "test-password",
});
it("configures Maven settings", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "test-agent",
nexus_url: "https://nexus.example.com",
nexus_password: "test-token",
package_managers: JSON.stringify({
maven: ["maven-public"],
}),
});
const output = await executeScriptInContainer(state, "ubuntu:20.04");
expect(output.stdout.join("\n")).toContain("☕ Configuring Maven...");
expect(output.stdout.join("\n")).toContain("🥳 Configuration complete!");
});
it("configures npm registry", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "test-agent",
nexus_url: "https://nexus.example.com",
nexus_password: "test-token",
package_managers: JSON.stringify({
npm: ["npm-public"],
}),
});
const output = await executeScriptInContainer(state, "ubuntu:20.04");
expect(output.stdout.join("\n")).toContain("📦 Configuring npm...");
expect(output.stdout.join("\n")).toContain("🥳 Configuration complete!");
});
it("configures PyPI repository", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "test-agent",
nexus_url: "https://nexus.example.com",
nexus_password: "test-token",
package_managers: JSON.stringify({
pypi: ["pypi-public"],
}),
});
const output = await executeScriptInContainer(state, "ubuntu:20.04");
expect(output.stdout.join("\n")).toContain("🐍 Configuring pip...");
expect(output.stdout.join("\n")).toContain("🥳 Configuration complete!");
});
it("configures multiple package managers", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "test-agent",
nexus_url: "https://nexus.example.com",
nexus_password: "test-token",
package_managers: JSON.stringify({
maven: ["maven-public"],
npm: ["npm-public"],
pypi: ["pypi-public"],
}),
});
const output = await executeScriptInContainer(state, "ubuntu:20.04");
expect(output.stdout.join("\n")).toContain("☕ Configuring Maven...");
expect(output.stdout.join("\n")).toContain("📦 Configuring npm...");
expect(output.stdout.join("\n")).toContain("🐍 Configuring pip...");
expect(output.stdout.join("\n")).toContain(
"✅ Nexus repository configuration completed!",
);
});
it("handles empty package managers", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "test-agent",
nexus_url: "https://nexus.example.com",
nexus_password: "test-token",
package_managers: JSON.stringify({}),
});
const output = await executeScriptInContainer(state, "ubuntu:20.04");
expect(output.stdout.join("\n")).toContain(
"🤔 no maven repository is set, skipping maven configuration.",
);
expect(output.stdout.join("\n")).toContain(
"🤔 no npm repository is set, skipping npm configuration.",
);
expect(output.stdout.join("\n")).toContain(
"🤔 no pypi repository is set, skipping pypi configuration.",
);
expect(output.stdout.join("\n")).toContain(
"🤔 no docker repository is set, skipping docker configuration.",
);
});
it("configures Go module proxy", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "test-agent",
nexus_url: "https://nexus.example.com",
nexus_password: "test-token",
package_managers: JSON.stringify({
go: ["go-public", "go-private"],
}),
});
const output = await executeScriptInContainer(state, "ubuntu:20.04");
expect(output.stdout.join("\n")).toContain("🐹 Configuring Go...");
expect(output.stdout.join("\n")).toContain(
"Go proxy configured via GOPROXY environment variable",
);
expect(output.stdout.join("\n")).toContain("🥳 Configuration complete!");
});
it("validates nexus_url format", async () => {
await expect(
runTerraformApply(import.meta.dir, {
agent_id: "test-agent",
nexus_url: "invalid-url",
nexus_password: "test-token",
package_managers: JSON.stringify({}),
}),
).rejects.toThrow();
});
it("validates username_field values", async () => {
await expect(
runTerraformApply(import.meta.dir, {
agent_id: "test-agent",
nexus_url: "https://nexus.example.com",
nexus_password: "test-token",
username_field: "invalid",
package_managers: JSON.stringify({}),
}),
).rejects.toThrow();
});
});
@@ -0,0 +1,137 @@
terraform {
required_version = ">= 1.0"
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.5"
}
}
}
variable "nexus_url" {
type = string
description = "The base URL of your Nexus repository manager (e.g. https://nexus.example.com)"
validation {
condition = can(regex("^(https|http)://", var.nexus_url))
error_message = "nexus_url must be a valid URL starting with either 'https://' or 'http://'"
}
}
variable "nexus_username" {
type = string
description = "Custom username for Nexus authentication. If not provided, defaults to the Coder username based on the username_field setting"
default = null
}
variable "nexus_password" {
type = string
description = "API token or password for Nexus authentication. This value is sensitive and should be stored securely"
sensitive = true
}
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
}
variable "package_managers" {
type = object({
maven = optional(list(string), [])
npm = optional(list(string), [])
go = optional(list(string), [])
pypi = optional(list(string), [])
docker = optional(list(string), [])
})
default = {
maven = []
npm = []
go = []
pypi = []
docker = []
}
description = <<-EOF
Configuration for package managers. Each key maps to a list of Nexus repository names:
- maven: List of Maven repository names
- npm: List of npm repository names (supports scoped packages with "@scope:repo-name")
- go: List of Go proxy repository names
- pypi: List of PyPI repository names
- docker: List of Docker registry names
Unused package managers can be omitted.
Example:
{
maven = ["maven-public", "maven-releases"]
npm = ["npm-public", "@scoped:npm-private"]
go = ["go-public", "go-private"]
pypi = ["pypi-public", "pypi-private"]
docker = ["docker-public", "docker-private"]
}
EOF
}
variable "username_field" {
type = string
description = "Field to use for username (\"username\" or \"email\"). Defaults to \"username\". Only used when nexus_username is not provided"
default = "username"
validation {
condition = can(regex("^(email|username)$", var.username_field))
error_message = "username_field must be either 'email' or 'username'"
}
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
locals {
username = coalesce(var.nexus_username, var.username_field == "email" ? data.coder_workspace_owner.me.email : data.coder_workspace_owner.me.name)
nexus_host = split("/", replace(replace(var.nexus_url, "https://", ""), "http://", ""))[0]
}
locals {
# Get first repository name or use default
maven_repo = length(var.package_managers.maven) > 0 ? var.package_managers.maven[0] : "maven-public"
npm_repo = length(var.package_managers.npm) > 0 ? var.package_managers.npm[0] : "npm-public"
go_repo = length(var.package_managers.go) > 0 ? var.package_managers.go[0] : "go-public"
pypi_repo = length(var.package_managers.pypi) > 0 ? var.package_managers.pypi[0] : "pypi-public"
npmrc = <<-EOF
registry=${var.nexus_url}/repository/${local.npm_repo}/
//${local.nexus_host}/repository/${local.npm_repo}/:username=${local.username}
//${local.nexus_host}/repository/${local.npm_repo}/:_password=${base64encode(var.nexus_password)}
//${local.nexus_host}/repository/${local.npm_repo}/:always-auth=true
EOF
}
resource "coder_script" "nexus" {
agent_id = var.agent_id
display_name = "nexus-repository"
icon = "/icon/nexus-repository.svg"
script = templatefile("${path.module}/run.sh", {
NEXUS_URL = var.nexus_url
NEXUS_HOST = local.nexus_host
NEXUS_USERNAME = local.username
NEXUS_PASSWORD = var.nexus_password
HAS_MAVEN = length(var.package_managers.maven) == 0 ? "" : "YES"
MAVEN_REPO = local.maven_repo
HAS_NPM = length(var.package_managers.npm) == 0 ? "" : "YES"
NPMRC = local.npmrc
HAS_GO = length(var.package_managers.go) == 0 ? "" : "YES"
GO_REPO = local.go_repo
HAS_PYPI = length(var.package_managers.pypi) == 0 ? "" : "YES"
PYPI_REPO = local.pypi_repo
HAS_DOCKER = length(var.package_managers.docker) == 0 ? "" : "YES"
REGISTER_DOCKER = join("\n ", formatlist("register_docker \"%s\"", var.package_managers.docker))
})
run_on_start = true
}
resource "coder_env" "goproxy" {
count = length(var.package_managers.go) == 0 ? 0 : 1
agent_id = var.agent_id
name = "GOPROXY"
value = join(",", [
for repo in var.package_managers.go :
"https://${local.username}:${var.nexus_password}@${local.nexus_host}/repository/${repo}"
])
}
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
not_configured() {
type=$1
echo "🤔 no $type repository is set, skipping $type configuration."
}
config_complete() {
echo "🥳 Configuration complete!"
}
register_docker() {
repo=$1
echo -n "${NEXUS_PASSWORD}" | docker login "${NEXUS_HOST}/repository/$${repo}" --username "${NEXUS_USERNAME}" --password-stdin
}
echo "🚀 Configuring Nexus repository access..."
# Configure Maven
if [ -n "${HAS_MAVEN}" ]; then
echo "☕ Configuring Maven..."
mkdir -p ~/.m2
cat > ~/.m2/settings.xml << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0">
<servers>
<server>
<id>nexus</id>
<username>${NEXUS_USERNAME}</username>
<password>${NEXUS_PASSWORD}</password>
</server>
</servers>
<mirrors>
<mirror>
<id>nexus-mirror</id>
<mirrorOf>*</mirrorOf>
<url>${NEXUS_URL}/repository/${MAVEN_REPO}</url>
</mirror>
</mirrors>
</settings>
EOF
config_complete
else
not_configured maven
fi
# Configure npm
if [ -n "${HAS_NPM}" ]; then
echo "📦 Configuring npm..."
cat > ~/.npmrc << 'EOF'
${NPMRC}
EOF
config_complete
else
not_configured npm
fi
# Configure Go
if [ -n "${HAS_GO}" ]; then
echo "🐹 Configuring Go..."
# Go configuration is handled via GOPROXY environment variable
# which is set by the Terraform configuration
echo "Go proxy configured via GOPROXY environment variable"
config_complete
else
not_configured go
fi
# Configure pip
if [ -n "${HAS_PYPI}" ]; then
echo "🐍 Configuring pip..."
mkdir -p ~/.pip
# Create .netrc file for secure credential storage
cat > ~/.netrc << EOF
machine ${NEXUS_HOST}
login ${NEXUS_USERNAME}
password ${NEXUS_PASSWORD}
EOF
chmod 600 ~/.netrc
# Update pip.conf to use index-url without embedded credentials
cat > ~/.pip/pip.conf << 'EOF'
[global]
index-url = https://${NEXUS_HOST}/repository/${PYPI_REPO}/simple
EOF
config_complete
else
not_configured pypi
fi
# Configure Docker
if [ -n "${HAS_DOCKER}" ]; then
if command -v docker > /dev/null 2>&1; then
echo "🐳 Configuring Docker credentials..."
mkdir -p ~/.docker
${REGISTER_DOCKER}
config_complete
else
echo "🤔 Docker is not installed, skipping Docker configuration."
fi
else
not_configured docker
fi
echo "✅ Nexus repository configuration completed!"
+69 -3
View File
@@ -1,7 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
# Find all directories that contain any .tftest.hcl files and run terraform test in each
# Auto-detect which Terraform tests to run based on changed files from paths-filter
# Uses paths-filter outputs from GitHub Actions:
# ALL_CHANGED_FILES - all files changed in the PR (for logging)
# SHARED_CHANGED - boolean indicating if shared infrastructure changed
# MODULE_CHANGED_FILES - only files in registry/**/modules/** (for processing)
# Runs all tests if shared infrastructure changes, or skips if no changes detected
#
# This script only runs tests for changed modules. Documentation and template changes are ignored.
run_dir() {
local dir="$1"
@@ -9,13 +16,72 @@ run_dir() {
(cd "$dir" && terraform init -upgrade -input=false -no-color > /dev/null && terraform test -no-color -verbose)
}
mapfile -t test_dirs < <(find . -type f -name "*.tftest.hcl" -print0 | xargs -0 -I{} dirname {} | sort -u)
echo "==> Detecting changed files..."
if [[ -n "${ALL_CHANGED_FILES:-}" ]]; then
echo "Changed files in PR:"
echo "$ALL_CHANGED_FILES" | tr ' ' '\n' | sed 's/^/ - /'
echo ""
fi
if [[ "${SHARED_CHANGED:-false}" == "true" ]]; then
echo "==> Shared infrastructure changed"
echo "==> Running all tests for safety"
mapfile -t test_dirs < <(find . -type f -name "*.tftest.hcl" -print0 | xargs -0 -I{} dirname {} | sort -u)
elif [[ -z "${MODULE_CHANGED_FILES:-}" ]]; then
echo "✓ No module files changed, skipping tests"
exit 0
else
CHANGED_FILES=$(echo "$MODULE_CHANGED_FILES" | tr ' ' '\n')
MODULE_DIRS=()
while IFS= read -r file; do
if [[ "$file" =~ \.(md|png|jpg|jpeg|svg)$ ]]; then
continue
fi
if [[ "$file" =~ ^registry/([^/]+)/modules/([^/]+)/ ]]; then
namespace="${BASH_REMATCH[1]}"
module="${BASH_REMATCH[2]}"
module_dir="registry/${namespace}/modules/${module}"
if [[ -d "$module_dir" ]] && [[ ! " ${MODULE_DIRS[*]} " =~ " ${module_dir} " ]]; then
MODULE_DIRS+=("$module_dir")
fi
fi
done <<< "$CHANGED_FILES"
if [[ ${#MODULE_DIRS[@]} -eq 0 ]]; then
echo "✓ No Terraform tests to run"
echo " (documentation, templates, namespace files, or modules without changes)"
exit 0
fi
echo "==> Finding .tftest.hcl files in ${#MODULE_DIRS[@]} changed module(s):"
for dir in "${MODULE_DIRS[@]}"; do
echo " - $dir"
done
echo ""
test_dirs=()
for module_dir in "${MODULE_DIRS[@]}"; do
while IFS= read -r test_file; do
test_dir=$(dirname "$test_file")
if [[ ! " ${test_dirs[*]} " =~ " ${test_dir} " ]]; then
test_dirs+=("$test_dir")
fi
done < <(find "$module_dir" -type f -name "*.tftest.hcl")
done
fi
if [[ ${#test_dirs[@]} -eq 0 ]]; then
echo "No .tftest.hcl tests found."
echo "No .tftest.hcl tests found in changed modules"
exit 0
fi
echo "==> Running terraform test in ${#test_dirs[@]} directory(ies)"
echo ""
status=0
for d in "${test_dirs[@]}"; do
if ! run_dir "$d"; then
+66 -12
View File
@@ -2,36 +2,90 @@
set -euo pipefail
# Auto-detect which Terraform modules to validate based on changed files from paths-filter
# Uses paths-filter outputs from GitHub Actions:
# ALL_CHANGED_FILES - all files changed in the PR (for logging)
# SHARED_CHANGED - boolean indicating if shared infrastructure changed
# MODULE_CHANGED_FILES - only files in registry/**/modules/** (for processing)
# Validates all modules if shared infrastructure changes, or skips if no changes detected
#
# This script only validates changed modules. Documentation and template changes are ignored.
validate_terraform_directory() {
local dir="$1"
echo "Running \`terraform validate\` in $dir"
pushd "$dir"
pushd "$dir" > /dev/null
terraform init -upgrade
terraform validate
popd
popd > /dev/null
}
main() {
# Get the directory of the script
echo "==> Detecting changed files..."
if [[ -n "${ALL_CHANGED_FILES:-}" ]]; then
echo "Changed files in PR:"
echo "$ALL_CHANGED_FILES" | tr ' ' '\n' | sed 's/^/ - /'
echo ""
fi
local script_dir=$(dirname "$(readlink -f "$0")")
local registry_dir=$(readlink -f "$script_dir/../registry")
# Code assumes that registry directory will always be in same position
# relative to the main script directory
local registry_dir="$script_dir/../registry"
if [[ "${SHARED_CHANGED:-false}" == "true" ]]; then
echo "==> Shared infrastructure changed"
echo "==> Validating all modules for safety"
local subdirs=$(find "$registry_dir" -mindepth 3 -maxdepth 3 -path "*/modules/*" -type d | sort)
elif [[ -z "${MODULE_CHANGED_FILES:-}" ]]; then
echo "✓ No module files changed, skipping validation"
exit 0
else
CHANGED_FILES=$(echo "$MODULE_CHANGED_FILES" | tr ' ' '\n')
# Get all module subdirectories in the registry directory. Code assumes that
# Terraform module directories won't begin to appear until three levels deep into
# the registry (e.g., registry/coder/modules/coder-login, which will then
# have a main.tf file inside it)
local subdirs=$(find "$registry_dir" -mindepth 3 -path "*/modules/*" -type d | sort)
MODULE_DIRS=()
while IFS= read -r file; do
if [[ "$file" =~ \.(md|png|jpg|jpeg|svg)$ ]]; then
continue
fi
if [[ "$file" =~ ^registry/([^/]+)/modules/([^/]+)/ ]]; then
namespace="${BASH_REMATCH[1]}"
module="${BASH_REMATCH[2]}"
module_dir="registry/${namespace}/modules/${module}"
if [[ -d "$module_dir" ]] && [[ ! " ${MODULE_DIRS[*]} " =~ " ${module_dir} " ]]; then
MODULE_DIRS+=("$module_dir")
fi
fi
done <<< "$CHANGED_FILES"
if [[ ${#MODULE_DIRS[@]} -eq 0 ]]; then
echo "✓ No modules to validate"
echo " (documentation, templates, namespace files, or modules without changes)"
exit 0
fi
echo "==> Validating ${#MODULE_DIRS[@]} changed module(s):"
for dir in "${MODULE_DIRS[@]}"; do
echo " - $dir"
done
echo ""
local subdirs="${MODULE_DIRS[*]}"
fi
status=0
for dir in $subdirs; do
# Skip over any directories that obviously don't have the necessary
# files
if test -f "$dir/main.tf"; then
validate_terraform_directory "$dir"
if ! validate_terraform_directory "$dir"; then
status=1
fi
fi
done
exit $status
}
main
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
set -euo pipefail
# Auto-detect which TypeScript tests to run based on changed files from paths-filter
# Uses paths-filter outputs from GitHub Actions:
# ALL_CHANGED_FILES - all files changed in the PR (for logging)
# SHARED_CHANGED - boolean indicating if shared infrastructure changed
# MODULE_CHANGED_FILES - only files in registry/**/modules/** (for processing)
# Runs all tests if shared infrastructure changes
#
# This script only runs tests for changed modules. Documentation and template changes are ignored.
echo "==> Detecting changed files..."
if [[ -n "${ALL_CHANGED_FILES:-}" ]]; then
echo "Changed files in PR:"
echo "$ALL_CHANGED_FILES" | tr ' ' '\n' | sed 's/^/ - /'
echo ""
fi
if [[ "${SHARED_CHANGED:-false}" == "true" ]]; then
echo "==> Shared infrastructure changed"
echo "==> Running all tests for safety"
exec bun test
fi
if [[ -z "${MODULE_CHANGED_FILES:-}" ]]; then
echo "✓ No module files changed, skipping tests"
exit 0
fi
CHANGED_FILES=$(echo "$MODULE_CHANGED_FILES" | tr ' ' '\n')
MODULE_DIRS=()
while IFS= read -r file; do
if [[ "$file" =~ \.(md|png|jpg|jpeg|svg)$ ]]; then
continue
fi
if [[ "$file" =~ ^registry/([^/]+)/modules/([^/]+)/ ]]; then
namespace="${BASH_REMATCH[1]}"
module="${BASH_REMATCH[2]}"
module_dir="registry/${namespace}/modules/${module}"
if [[ -f "$module_dir/main.test.ts" ]] && [[ ! " ${MODULE_DIRS[*]} " =~ " ${module_dir} " ]]; then
MODULE_DIRS+=("$module_dir")
fi
fi
done <<< "$CHANGED_FILES"
if [[ ${#MODULE_DIRS[@]} -eq 0 ]]; then
echo "✓ No TypeScript tests to run"
echo " (documentation, templates, namespace files, or modules without tests)"
exit 0
fi
echo "==> Running TypeScript tests for ${#MODULE_DIRS[@]} changed module(s):"
for dir in "${MODULE_DIRS[@]}"; do
echo " - $dir"
done
echo ""
exec bun test "${MODULE_DIRS[@]}"