mirror of
https://github.com/coder/registry.git
synced 2026-06-03 13:08:14 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d638371a85 | |||
| e34320cb0b | |||
| ca7bc42946 | |||
| a599302774 | |||
| ff09c415e8 | |||
| 90873e8009 | |||
| 2168360195 |
@@ -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,10 +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
|
||||
run: ./scripts/terraform_test_all.sh
|
||||
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
|
||||
|
||||
@@ -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: 202 KiB |
+2
-1
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.1"
|
||||
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.1"
|
||||
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.1"
|
||||
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.1"
|
||||
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 () => {
|
||||
|
||||
@@ -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,16 +162,18 @@ module "agentapi" {
|
||||
version = "1.2.0"
|
||||
|
||||
agent_id = var.agent_id
|
||||
folder = var.folder
|
||||
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
|
||||
@@ -152,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
|
||||
@@ -165,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+63
@@ -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[@]}"
|
||||
Reference in New Issue
Block a user