diff --git a/registry/coder/modules/archive/README.md b/registry/coder/modules/archive/README.md new file mode 100644 index 000000000..a9214d121 --- /dev/null +++ b/registry/coder/modules/archive/README.md @@ -0,0 +1,167 @@ +--- +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/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 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`. +- 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. + +## Usage + +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 + + # 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/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/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 +} +``` + +## 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` + +## 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 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: + +```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 + ``` + +- 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 + ``` 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..3f51ff033 --- /dev/null +++ b/registry/coder/modules/archive/main.test.ts @@ -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 => { + 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 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); +}); diff --git a/registry/coder/modules/archive/main.tf b/registry/coder/modules/archive/main.tf new file mode 100644 index 000000000..6157ebbcd --- /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$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-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 +}