From 34eca10c4e5e6b58b4eba7a044571f97e16be191 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 16 Sep 2025 14:37:06 +0000 Subject: [PATCH 1/6] feat(registry/coder/modules): add archive module --- registry/coder/modules/archive/README.md | 179 ++++++++++ .../coder/modules/archive/archive.tftest.hcl | 33 ++ registry/coder/modules/archive/main.test.ts | 338 ++++++++++++++++++ registry/coder/modules/archive/main.tf | 134 +++++++ registry/coder/modules/archive/run.sh | 68 ++++ .../modules/archive/scripts/archive-lib.sh | 278 ++++++++++++++ 6 files changed, 1030 insertions(+) create mode 100644 registry/coder/modules/archive/README.md create mode 100644 registry/coder/modules/archive/archive.tftest.hcl create mode 100644 registry/coder/modules/archive/main.test.ts create mode 100644 registry/coder/modules/archive/main.tf create mode 100644 registry/coder/modules/archive/run.sh create mode 100644 registry/coder/modules/archive/scripts/archive-lib.sh diff --git a/registry/coder/modules/archive/README.md b/registry/coder/modules/archive/README.md new file mode 100644 index 000000000..899d61d63 --- /dev/null +++ b/registry/coder/modules/archive/README.md @@ -0,0 +1,179 @@ +--- +display_name: Archive +description: Create automated and user-invocable scripts that archive and extract selected files/directories with gzip, zstd, or none. +icon: ../../../../.icons/tool.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 gzip, zstd, or no compression. 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 and extract it on start. + +- Depends on: `tar` (and `gzip` or `zstd` if you select those compression modes) +- Installed scripts: + - `$CODER_SCRIPT_BIN_DIR/coder-archive-create` + - `$CODER_SCRIPT_BIN_DIR/coder-archive-extract` +- Library installed to: + - `$CODER_SCRIPT_DATA_DIR/archive-lib.sh` +- On start (always): installer decodes and writes the library, then generates the wrappers in `$CODER_SCRIPT_BIN_DIR` with Terraform-provided defaults embedded. +- Optional on stop: when enabled, a separate `run_on_stop` script invokes the create command. + +## Features + +- Create a single `.tar`, `.tar.gz`, or `.tar.zst` containing selected paths. +- Compression algorithms: `gzip`, `zstd`, `none`. +- Defaults for directory, archive path, compression, include/exclude lists come from Terraform and can be overridden at runtime with CLI flags. +- Logs and status messages go to stderr; the create command prints only the final archive path to stdout. +- Strict bash mode and safe invocation of `tar`. +- Optional: + - `run_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 (with timeout). + +## Usage + +Basic example: + + module "archive" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/archive/coder" + version = "0.0.1" + agent_id = coder_agent.example.id + + # Paths to include in the archive (files or directories). + directory = "/" + paths = [ + "/etc/hostname", + "/etc/hosts", + ] + } + +Customize compression and output: + + module "archive" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/archive/coder" + version = "0.0.1" + agent_id = coder_agent.example.id + + paths = ["/etc/hostname"] + 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: + + module "archive" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/archive/coder" + version = "0.0.1" + agent_id = coder_agent.example.id + + paths = ["/etc/hostname"] + compression = "gzip" + create_on_stop = true + } + +Extract on start (optional): + + module "archive" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/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 = "coder-archive" + compression = "gzip" + + extract_on_start = true + extract_wait_timeout_seconds = 300 + } + +## Running the scripts + +Once the workspace starts, the installer writes: + +- `$CODER_SCRIPT_DATA_DIR/archive-lib.sh` +- `$CODER_SCRIPT_BIN_DIR/coder-archive-create` +- `$CODER_SCRIPT_BIN_DIR/coder-archive-extract` + +Create usage: + + coder-archive-create [OPTIONS] [PATHS...] + -c, --compression Compression algorithm (default from module) + -C, --directory Change to directory for archiving (default from module) + -f, --file Output archive file (default from module) + -h, --help Show help + +Extract usage: + + coder-archive-extract [OPTIONS] + -c, --compression Compression algorithm (default from module) + -C, --directory Extract into directory (default from module) + -f, --file 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 + +Notes: + +- Create prints only the final archive path to stdout. All other output (progress, warnings, errors) goes to stderr. +- Extract prints a short message to stdout indicating the destination. +- Exclude patterns from Terraform are forwarded to `tar` using `--exclude`. +- You can run the wrappers with bash xtrace for more debug information: + - `bash -x "$(which coder-archive-create)" ...` + +## Inputs + +- `agent_id` (string, required): The ID of a Coder agent. +- `paths` (list(string), default: `["."]`): Files/directories to include when creating an archive. +- `exclude_patterns` (list(string), default: `[]`): Patterns to exclude (passed to tar via `--exclude`). +- `compression` (string, default: `"gzip"`): One of `gzip`, `zstd`, or `none`. +- `archive_name` (string, default: `"coder-archive"`): Base archive name (extension is inferred from `compression`). +- `output_dir` (string, default: `"/tmp"`): Directory where the archive file will be written/read by default. +- `directory` (string, default: `"~"`): Working directory used for tar with `-C`. +- `create_on_stop` (bool, default: `false`): If true, registers a `run_on_stop` script that invokes the create wrapper on workspace stop. +- `extract_on_start` (bool, default: `false`): If true, the installer waits up to `extract_wait_timeout_seconds` for the archive path to appear and extracts it on start. +- `extract_wait_timeout_seconds` (number, default: `300`): Timeout for `extract_on_start`. + +## Outputs + +- `archive_path` (string): Full archive path computed as `output_dir/archive_name + extension`, where the extension comes from `compression`: + - `.tar.gz` for `gzip` + - `.tar.zst` for `zstd` + - `.tar` for `none` + +## Requirements + +- `tar` is required. +- `gzip` is required if `compression = "gzip"`. +- `zstd` is required if `compression = "zstd"`. + +## Behavior + +- On start, the installer: + - Decodes the embedded library to `$CODER_SCRIPT_DATA_DIR/archive-lib.sh`. + - Generates two wrappers in `$CODER_SCRIPT_BIN_DIR`: `coder-archive-create` and `coder-archive-extract`. + - Embeds Terraform-provided defaults into the wrappers. + - If `extract_on_start` is true, the installer sources the library and calls `archive_wait_and_extract`, waiting up to `extract_wait_timeout_seconds` for the file at `archive_path` to appear. +- If `create_on_stop` is true, a `run_on_stop` script is registered that invokes the create command at stop. +- `umask 077` is applied during operations so archives and extracted files are created with restrictive permissions. diff --git a/registry/coder/modules/archive/archive.tftest.hcl b/registry/coder/modules/archive/archive.tftest.hcl new file mode 100644 index 000000000..944ddbb18 --- /dev/null +++ b/registry/coder/modules/archive/archive.tftest.hcl @@ -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" + } +} diff --git a/registry/coder/modules/archive/main.test.ts b/registry/coder/modules/archive/main.test.ts new file mode 100644 index 000000000..1cc40eeef --- /dev/null +++ b/registry/coder/modules/archive/main.test.ts @@ -0,0 +1,338 @@ +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 => { + const res = await execContainer(id, ["sh", "-c", cmd]); + return res; +}; + +const bashRun = async (id: string, cmd: string): Promise => { + 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 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); + + // We don't assert specific entries to avoid environment coupling. + expect(list.stdout.length).toBeGreaterThan(0); + }, 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: `["/etc/hostname"]`, + compression: "gzip", + }); + + const { id } = await installArchive(state); + + const out = "/tmp/backup/test-archive.tar.gz"; + const run = await bashRun( + id, + `${BIN_DIR}/coder-archive-create -f ${out} /etc/hosts`, + ); + ensureRunOk("archive-create gzip explicit -f", run); + + expect(run.stdout.trim()).toEqual(out); + expect(await fileExists(id, out)).toBe(true); + + const tarList = await sh(id, `tar -tzf ${out}`); + ensureRunOk("tar -tzf contents (gzip)", tarList); + expect(tarList.stdout).toContain("etc/hosts"); + expect(tarList.stdout).toContain("etc/hostname"); + }, 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); +}); diff --git a/registry/coder/modules/archive/main.tf b/registry/coder/modules/archive/main.tf new file mode 100644 index 000000000..d09e4b1ce --- /dev/null +++ b/registry/coder/modules/archive/main.tf @@ -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 = 300 +} + +# 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")] + exclude_patterns = [for v in var.exclude_patterns : replace(v, "/^~/", "$$HOME")] + directory = replace(var.directory, "/^~/", "$$HOME") + output_dir = replace(var.output_dir, "/^~/", "$$HOME") + + 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-zip.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-zip.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-create-archive" + EOT +} diff --git a/registry/coder/modules/archive/run.sh b/registry/coder/modules/archive/run.sh new file mode 100644 index 000000000..1c3e1b573 --- /dev/null +++ b/registry/coder/modules/archive/run.sh @@ -0,0 +1,68 @@ +#!/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" +fi diff --git a/registry/coder/modules/archive/scripts/archive-lib.sh b/registry/coder/modules/archive/scripts/archive-lib.sh new file mode 100644 index 000000000..47caf1b88 --- /dev/null +++ b/registry/coder/modules/archive/scripts/archive-lib.sh @@ -0,0 +1,278 @@ +#!/usr/bin/env bash +set -euo pipefail + +# set -x + +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-create-archive [OPTIONS] [[PATHS] ...] +Options: + -c, --compression Compression algorithm (default "${DEFAULT_COMPRESSION}") + -C, --directory Change to directory (default "${DEFAULT_DIRECTORY}") + -f, --file 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-extract-archive [OPTIONS] +Options: + -c, --compression Compression algorithm (default "${DEFAULT_COMPRESSION}") + -C, --directory Change to directory (default "${DEFAULT_DIRECTORY}") + -f, --file 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 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 + + printf 'ERROR: Timed out waiting for archive: %s\n' "$file" >&2 + return 1 +} From a932850b0fa739e2d8588a5a9f92f4b1c8faa5a0 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 19 Sep 2025 18:57:10 +0300 Subject: [PATCH 2/6] Apply suggestion from @johnstcn Co-authored-by: Cian Johnston --- registry/coder/modules/archive/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registry/coder/modules/archive/README.md b/registry/coder/modules/archive/README.md index 899d61d63..add08d061 100644 --- a/registry/coder/modules/archive/README.md +++ b/registry/coder/modules/archive/README.md @@ -1,6 +1,6 @@ --- display_name: Archive -description: Create automated and user-invocable scripts that archive and extract selected files/directories with gzip, zstd, or none. +description: Create automated and user-invocable scripts that archive and extract selected files/directories with optional compression (gzip or zstd). icon: ../../../../.icons/tool.svg verified: false tags: [backup, archive, tar, helper] From 8010f5827825dd633a94311dca9345a9c8ed7fe4 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 19 Sep 2025 18:57:23 +0300 Subject: [PATCH 3/6] Apply suggestion from @johnstcn Co-authored-by: Cian Johnston --- registry/coder/modules/archive/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registry/coder/modules/archive/README.md b/registry/coder/modules/archive/README.md index add08d061..92fdacb64 100644 --- a/registry/coder/modules/archive/README.md +++ b/registry/coder/modules/archive/README.md @@ -8,7 +8,7 @@ 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 gzip, zstd, or no compression. 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 and extract it on start. +This module installs small, robust scripts in your workspace to create and extract tar archives from a list of files and directories. It supports gzip, zstd, or no compression. 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. - Depends on: `tar` (and `gzip` or `zstd` if you select those compression modes) - Installed scripts: From 822cf159937c5ef5f664e9fc1a762588988be6d0 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 22 Sep 2025 08:58:16 +0000 Subject: [PATCH 4/6] clean up docs --- registry/coder/modules/archive/README.md | 235 +++++++++++------------ 1 file changed, 112 insertions(+), 123 deletions(-) diff --git a/registry/coder/modules/archive/README.md b/registry/coder/modules/archive/README.md index 92fdacb64..453827125 100644 --- a/registry/coder/modules/archive/README.md +++ b/registry/coder/modules/archive/README.md @@ -8,93 +8,114 @@ 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 gzip, zstd, or no compression. 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. +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. -- Depends on: `tar` (and `gzip` or `zstd` if you select those compression modes) -- Installed scripts: - - `$CODER_SCRIPT_BIN_DIR/coder-archive-create` - - `$CODER_SCRIPT_BIN_DIR/coder-archive-extract` -- Library installed to: - - `$CODER_SCRIPT_DATA_DIR/archive-lib.sh` -- On start (always): installer decodes and writes the library, then generates the wrappers in `$CODER_SCRIPT_BIN_DIR` with Terraform-provided defaults embedded. -- Optional on stop: when enabled, a separate `run_on_stop` script invokes the create command. ## Features -- Create a single `.tar`, `.tar.gz`, or `.tar.zst` containing selected paths. -- Compression algorithms: `gzip`, `zstd`, `none`. -- Defaults for directory, archive path, compression, include/exclude lists come from Terraform and can be overridden at runtime with CLI flags. -- Logs and status messages go to stderr; the create command prints only the final archive path to stdout. -- Strict bash mode and safe invocation of `tar`. +- 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: - - `run_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 (with timeout). + - `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. ## Usage Basic example: - module "archive" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/coder/archive/coder" - version = "0.0.1" - agent_id = coder_agent.example.id - - # Paths to include in the archive (files or directories). - directory = "/" - paths = [ - "/etc/hostname", - "/etc/hosts", - ] - } +```tf +module "archive" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/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: - module "archive" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/coder/archive/coder" - version = "0.0.1" - agent_id = coder_agent.example.id - - paths = ["/etc/hostname"] - compression = "zstd" # "gzip" | "zstd" | "none" - output_dir = "/tmp/backup" # defaults to /tmp - archive_name = "my-backup" # base name (extension is inferred from compression) - } +```tf +module "archive" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/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: - module "archive" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/coder/archive/coder" - version = "0.0.1" - agent_id = coder_agent.example.id - - paths = ["/etc/hostname"] - compression = "gzip" - create_on_stop = true - } +```tf +module "archive" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/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/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. + extract_on_start = true + extract_wait_timeout_seconds = 300 +} +``` -Extract on start (optional): +## Inputs - module "archive" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/coder/archive/coder" - version = "0.0.1" - agent_id = coder_agent.example.id +- `agent_id` (string, required): The ID of a Coder agent. +- `paths` (list(string), default: `["."]`): Files/directories to include when creating an archive. +- `exclude_patterns` (list(string), default: `[]`): Patterns to exclude (passed to tar via `--exclude`). +- `compression` (string, default: `"gzip"`): One of `gzip`, `zstd`, or `none`. +- `archive_name` (string, default: `"coder-archive"`): Base archive name (extension is inferred from `compression`). +- `output_dir` (string, default: `"/tmp"`): Directory where the archive file will be written/read by default. +- `directory` (string, default: `"~"`): Working directory used for tar with `-C`. +- `create_on_stop` (bool, default: `false`): If true, registers a `run_on_stop` script that invokes the create wrapper on workspace stop. +- `extract_on_start` (bool, default: `false`): If true, the installer waits up to `extract_wait_timeout_seconds` for the archive path to appear and extracts it on start. +- `extract_wait_timeout_seconds` (number, default: `300`): Timeout for `extract_on_start`. - # Where to look for the archive file to extract: - output_dir = "/tmp" - archive_name = "coder-archive" - compression = "gzip" +## Outputs - extract_on_start = true - extract_wait_timeout_seconds = 300 - } +- `archive_path` (string): Full archive path computed as `output_dir/archive_name + extension`, where the extension comes from `compression`: + - `.tar.gz` for `gzip` + - `.tar.zst` for `zstd` + - `.tar` for `none` -## Running the scripts +## Command usage -Once the workspace starts, the installer writes: +The installer writes the following files: - `$CODER_SCRIPT_DATA_DIR/archive-lib.sh` - `$CODER_SCRIPT_BIN_DIR/coder-archive-create` @@ -102,78 +123,46 @@ Once the workspace starts, the installer writes: Create usage: - coder-archive-create [OPTIONS] [PATHS...] - -c, --compression Compression algorithm (default from module) - -C, --directory Change to directory for archiving (default from module) - -f, --file Output archive file (default from module) - -h, --help Show help +```console +coder-archive-create [OPTIONS] [PATHS...] + -c, --compression Compression algorithm (default from module) + -C, --directory Change to directory for archiving (default from module) + -f, --file Output archive file (default from module) + -h, --help Show help +``` Extract usage: - coder-archive-extract [OPTIONS] - -c, --compression Compression algorithm (default from module) - -C, --directory Extract into directory (default from module) - -f, --file Archive file to extract (default from module) - -h, --help Show help +```console +coder-archive-extract [OPTIONS] + -c, --compression Compression algorithm (default from module) + -C, --directory Extract into directory (default from module) + -f, --file Archive file to extract (default from module) + -h, --help Show help +``` Examples: - Use Terraform defaults: - coder-archive-create + ``` + coder-archive-create + ``` - Override compression and output file at runtime: - coder-archive-create --compression zstd --file /tmp/backups/archive.tar.zst + ``` + 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 + ``` + coder-archive-create /etc/hosts + ``` - Extract an archive into a directory: - coder-archive-extract --file /tmp/backups/archive.tar.gz --directory /tmp/restore - -Notes: - -- Create prints only the final archive path to stdout. All other output (progress, warnings, errors) goes to stderr. -- Extract prints a short message to stdout indicating the destination. -- Exclude patterns from Terraform are forwarded to `tar` using `--exclude`. -- You can run the wrappers with bash xtrace for more debug information: - - `bash -x "$(which coder-archive-create)" ...` - -## Inputs - -- `agent_id` (string, required): The ID of a Coder agent. -- `paths` (list(string), default: `["."]`): Files/directories to include when creating an archive. -- `exclude_patterns` (list(string), default: `[]`): Patterns to exclude (passed to tar via `--exclude`). -- `compression` (string, default: `"gzip"`): One of `gzip`, `zstd`, or `none`. -- `archive_name` (string, default: `"coder-archive"`): Base archive name (extension is inferred from `compression`). -- `output_dir` (string, default: `"/tmp"`): Directory where the archive file will be written/read by default. -- `directory` (string, default: `"~"`): Working directory used for tar with `-C`. -- `create_on_stop` (bool, default: `false`): If true, registers a `run_on_stop` script that invokes the create wrapper on workspace stop. -- `extract_on_start` (bool, default: `false`): If true, the installer waits up to `extract_wait_timeout_seconds` for the archive path to appear and extracts it on start. -- `extract_wait_timeout_seconds` (number, default: `300`): Timeout for `extract_on_start`. - -## Outputs - -- `archive_path` (string): Full archive path computed as `output_dir/archive_name + extension`, where the extension comes from `compression`: - - `.tar.gz` for `gzip` - - `.tar.zst` for `zstd` - - `.tar` for `none` - -## Requirements - -- `tar` is required. -- `gzip` is required if `compression = "gzip"`. -- `zstd` is required if `compression = "zstd"`. - -## Behavior - -- On start, the installer: - - Decodes the embedded library to `$CODER_SCRIPT_DATA_DIR/archive-lib.sh`. - - Generates two wrappers in `$CODER_SCRIPT_BIN_DIR`: `coder-archive-create` and `coder-archive-extract`. - - Embeds Terraform-provided defaults into the wrappers. - - If `extract_on_start` is true, the installer sources the library and calls `archive_wait_and_extract`, waiting up to `extract_wait_timeout_seconds` for the file at `archive_path` to appear. -- If `create_on_stop` is true, a `run_on_stop` script is registered that invokes the create command at stop. -- `umask 077` is applied during operations so archives and extracted files are created with restrictive permissions. + ``` + coder-archive-extract --file /tmp/backups/archive.tar.gz --directory /tmp/restore + ``` From b48daf96000898c2921050c47c0d344add4138a5 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 22 Sep 2025 09:16:03 +0000 Subject: [PATCH 5/6] fix ~ edge cases and cover in tests --- registry/coder/modules/archive/main.test.ts | 28 ++++++++++++++------- registry/coder/modules/archive/main.tf | 8 +++--- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/registry/coder/modules/archive/main.test.ts b/registry/coder/modules/archive/main.test.ts index 1cc40eeef..3f51ff033 100644 --- a/registry/coder/modules/archive/main.test.ts +++ b/registry/coder/modules/archive/main.test.ts @@ -159,6 +159,12 @@ describe("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); @@ -172,35 +178,39 @@ describe("archive", () => { const list = await listTar(id, "/tmp/coder-archive.tar.gz"); ensureRunOk("list default archive", list); - - // We don't assert specific entries to avoid environment coupling. - expect(list.stdout.length).toBeGreaterThan(0); + 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: `["/etc/hostname"]`, + 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} /etc/hosts`, + `${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 tarList = await sh(id, `tar -tzf ${out}`); - ensureRunOk("tar -tzf contents (gzip)", tarList); - expect(tarList.stdout).toContain("etc/hosts"); - expect(tarList.stdout).toContain("etc/hostname"); + 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 () => { diff --git a/registry/coder/modules/archive/main.tf b/registry/coder/modules/archive/main.tf index d09e4b1ce..6157ebbcd 100644 --- a/registry/coder/modules/archive/main.tf +++ b/registry/coder/modules/archive/main.tf @@ -78,10 +78,10 @@ locals { # Ensure ~ is expanded because it cannot be expanded inside quotes in a # templated shell script. - paths = [for v in var.paths : replace(v, "/^~/", "$$HOME")] - exclude_patterns = [for v in var.exclude_patterns : replace(v, "/^~/", "$$HOME")] - directory = replace(var.directory, "/^~/", "$$HOME") - output_dir = replace(var.output_dir, "/^~/", "$$HOME") + 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}" } From b086f293d77326a8439dad73592f0c23c7cc3ed5 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Mon, 22 Sep 2025 09:45:56 +0000 Subject: [PATCH 6/6] fmt --- registry/coder/modules/archive/README.md | 55 ++++++++++++------------ 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/registry/coder/modules/archive/README.md b/registry/coder/modules/archive/README.md index 453827125..a9214d121 100644 --- a/registry/coder/modules/archive/README.md +++ b/registry/coder/modules/archive/README.md @@ -10,7 +10,6 @@ tags: [backup, archive, tar, helper] 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. - ## Features - Installs two commands into the workspace `$PATH`: `coder-archive-create` and `coder-archive-extract`. @@ -28,14 +27,14 @@ Basic example: ```tf module "archive" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/coder/archive/coder" - version = "0.0.1" - agent_id = coder_agent.example.id + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/archive/coder" + version = "0.0.1" + agent_id = coder_agent.example.id # Paths to include in the archive (files or directories). directory = "~" - paths = [ + paths = [ "./projects", "./code", ] @@ -46,16 +45,16 @@ Customize compression and output: ```tf module "archive" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/coder/archive/coder" - version = "0.0.1" - agent_id = coder_agent.example.id + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/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) + compression = "zstd" # "gzip" | "zstd" | "none" + output_dir = "/tmp/backup" # defaults to /tmp + archive_name = "my-backup" # base name (extension is inferred from compression) } ``` @@ -63,12 +62,12 @@ Enable auto-archive on stop: ```tf module "archive" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/coder/archive/coder" - version = "0.0.1" - agent_id = coder_agent.example.id + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/archive/coder" + version = "0.0.1" + agent_id = coder_agent.example.id - # Creates /tmp/coder-archive.tar.gz of the users home directory (defaults). + # Creates /tmp/coder-archive.tar.gz of the users home directory (defaults). create_on_stop = true } ``` @@ -77,19 +76,19 @@ Extract on start: ```tf module "archive" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/coder/archive/coder" - version = "0.0.1" - agent_id = coder_agent.example.id + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/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" + output_dir = "/tmp" + archive_name = "my-archive" + compression = "gzip" - # Waits up to 5 minutes for /tmp/my-archive.tar.gz to be present. - extract_on_start = true - extract_wait_timeout_seconds = 300 + # Waits up to 5 minutes for /tmp/my-archive.tar.gz to be present. + extract_on_start = true + extract_wait_timeout_seconds = 300 } ```