feat: add archive module (#422)

This change adds a new `archive` module to the Coder registry. It can be
used to archive user-data from pre-defined locations and restore it as
well.

Here we also explore:

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

---------

Co-authored-by: Cian Johnston <cian@coder.com>
Co-authored-by: DevCats <christofer@coder.com>
This commit is contained in:
Mathias Fredriksson 2025-10-17 16:14:56 +03:00 committed by GitHub
parent ca7bc42946
commit e34320cb0b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 1033 additions and 0 deletions

1
.icons/folder.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 289 B

View File

@ -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
```

View File

@ -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"
}
}

View File

@ -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);
});

View File

@ -0,0 +1,134 @@
terraform {
required_version = ">= 1.0"
required_providers {
coder = {
source = "coder/coder"
version = ">= 0.12"
}
}
}
variable "agent_id" {
description = "The ID of a Coder agent."
type = string
}
variable "paths" {
description = "List of files/directories to include in the archive. Defaults to the current directory."
type = list(string)
default = ["."]
}
variable "exclude_patterns" {
description = "Exclude patterns for the archive."
type = list(string)
default = []
}
variable "compression" {
description = "Compression algorithm for the archive. Supported: gzip, zstd, none."
type = string
default = "gzip"
validation {
condition = contains(["gzip", "zstd", "none"], var.compression)
error_message = "compression must be one of: gzip, zstd, none."
}
}
variable "archive_name" {
description = "Optional archive base name without extension. If empty, defaults to \"coder-archive\"."
type = string
default = "coder-archive"
}
variable "output_dir" {
description = "Optional output directory where the archive will be written. Defaults to \"/tmp\"."
type = string
default = "/tmp"
}
variable "directory" {
description = "Change current directory to this path before creating or extracting the archive. Defaults to the user's home directory."
type = string
default = "~"
}
variable "create_on_stop" {
description = "If true, also create a run_on_stop script that creates the archive automatically on workspace stop."
type = bool
default = false
}
variable "extract_on_start" {
description = "If true, the installer will wait for an archive and extract it on start."
type = bool
default = false
}
variable "extract_wait_timeout_seconds" {
description = "Timeout (seconds) to wait for an archive when extract_on_start is true."
type = number
default = 5
}
# Provide a stable script filename and sensible defaults.
locals {
extension = var.compression == "gzip" ? ".tar.gz" : var.compression == "zstd" ? ".tar.zst" : ".tar"
# Ensure ~ is expanded because it cannot be expanded inside quotes in a
# templated shell script.
paths = [for v in var.paths : replace(v, "/^~(\\/|$)/", "$$HOME$1")]
exclude_patterns = [for v in var.exclude_patterns : replace(v, "/^~(\\/|$)/", "$$HOME$1")]
directory = replace(var.directory, "/^~(\\/|$)/", "$$HOME$1")
output_dir = replace(var.output_dir, "/^~(\\/|$)/", "$$HOME$1")
archive_path = "${local.output_dir}/${var.archive_name}${local.extension}"
}
output "archive_path" {
description = "Full path to the archive file that will be created, extracted, or both."
value = local.archive_path
}
# This script installs the user-facing archive script into $CODER_SCRIPT_BIN_DIR.
# The installed script can be run manually by the user to create an archive.
resource "coder_script" "archive_start_script" {
agent_id = var.agent_id
display_name = "Archive"
icon = "/icon/folder.svg"
run_on_start = true
start_blocks_login = var.extract_on_start
# Render the user-facing archive script with Terraform defaults, then write it to $CODER_SCRIPT_BIN_DIR
script = templatefile("${path.module}/run.sh", {
TF_LIB_B64 = base64encode(file("${path.module}/scripts/archive-lib.sh")),
TF_PATHS = join(" ", formatlist("%q", local.paths)),
TF_EXCLUDE_PATTERNS = join(" ", formatlist("%q", local.exclude_patterns)),
TF_COMPRESSION = var.compression,
TF_ARCHIVE_PATH = local.archive_path,
TF_DIRECTORY = local.directory,
TF_EXTRACT_ON_START = var.extract_on_start,
TF_EXTRACT_WAIT_TIMEOUT = var.extract_wait_timeout_seconds,
})
}
# Optionally, also register a run_on_stop script that creates the archive automatically
# when the workspace stops. It simply invokes the installed archive script.
resource "coder_script" "archive_stop_script" {
count = var.create_on_stop ? 1 : 0
agent_id = var.agent_id
display_name = "Archive"
icon = "/icon/folder.svg"
run_on_stop = true
start_blocks_login = false
# Call the installed script. It will log to stderr and print the archive path to stdout.
# We redirect stdout to stderr to avoid surfacing the path in system logs if undesired.
# Remove the redirection if you want the path to appear in stdout on stop as well.
script = <<-EOT
#!/usr/bin/env bash
set -euo pipefail
"$CODER_SCRIPT_BIN_DIR/coder-archive-create"
EOT
}

View File

@ -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

View File

@ -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
}