From d638371a85e20fb80dae9d160127d0ed37b28b15 Mon Sep 17 00:00:00 2001 From: DevelopmentCats Date: Mon, 20 Oct 2025 13:58:22 -0500 Subject: [PATCH] feat: initial commit for restic --- .icons/restic.svg | 590 ++++++++++++++++++ registry/coder/modules/restic/README.md | 523 ++++++++++++++++ registry/coder/modules/restic/main.test.ts | 75 +++ registry/coder/modules/restic/main.tf | 271 ++++++++ .../coder/modules/restic/restic.tftest.hcl | 333 ++++++++++ .../coder/modules/restic/scripts/backup.sh | 104 +++ registry/coder/modules/restic/scripts/run.sh | 296 +++++++++ 7 files changed, 2192 insertions(+) create mode 100644 .icons/restic.svg create mode 100644 registry/coder/modules/restic/README.md create mode 100644 registry/coder/modules/restic/main.test.ts create mode 100644 registry/coder/modules/restic/main.tf create mode 100644 registry/coder/modules/restic/restic.tftest.hcl create mode 100644 registry/coder/modules/restic/scripts/backup.sh create mode 100644 registry/coder/modules/restic/scripts/run.sh diff --git a/.icons/restic.svg b/.icons/restic.svg new file mode 100644 index 000000000..976d894a9 --- /dev/null +++ b/.icons/restic.svg @@ -0,0 +1,590 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/registry/coder/modules/restic/README.md b/registry/coder/modules/restic/README.md new file mode 100644 index 000000000..6cd25a45a --- /dev/null +++ b/registry/coder/modules/restic/README.md @@ -0,0 +1,523 @@ +--- +display_name: Restic Backup +description: Cloud-backed ephemeral workspaces with automatic backup on stop and restore on start using Restic +icon: ../../../../.icons/restic.svg +verified: false +tags: [backup, restore, cloud, restic, s3, b2] +--- + +# Restic Backup + +Automatic cloud backups for Coder workspaces. Backs up on stop, restores on start. + +## Features + +- Auto backup/restore on workspace stop/start +- Works with S3, B2, Azure, GCS, SFTP, local storage +- Encrypted and deduplicated +- Workspace-aware tagging for easy browsing +- Configurable retention policies +- Clone backups between workspaces + +## Quick Start + +```tf +module "restic" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/restic/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + repository = "s3:s3.amazonaws.com/my-workspace-backups" + password = var.restic_password + + env = { + AWS_ACCESS_KEY_ID = var.aws_access_key + AWS_SECRET_ACCESS_KEY = var.aws_secret_key + } +} +``` + +## How It Works + +1. Workspace stops → automatic backup to cloud +2. Workspace starts → automatic restore from backup +3. Backups are tagged with `workspace-id`, `workspace-owner`, `workspace-name` +4. Auto-restore uses `workspace-id` to find the correct backup +5. Manually restore any backup using `snapshot_id` + +## Storage Backend Configuration + +### AWS S3 + +[Official Restic S3 Documentation](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#amazon-s3) + +```tf +module "restic" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/restic/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + repository = "s3:s3.amazonaws.com/my-bucket/workspace-backups" + password = var.restic_password + + env = { + AWS_ACCESS_KEY_ID = var.aws_access_key + AWS_SECRET_ACCESS_KEY = var.aws_secret_key + AWS_DEFAULT_REGION = "us-east-1" + } +} +``` + +### Backblaze B2 (Cost-Effective) + +[Official Restic B2 Documentation](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#backblaze-b2) + +```tf +module "restic" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/restic/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + repository = "b2:my-bucket:workspace-backups" + password = var.restic_password + + env = { + B2_ACCOUNT_ID = var.b2_account_id + B2_ACCOUNT_KEY = var.b2_account_key + } +} +``` + +### Azure Blob Storage + +[Official Restic Azure Documentation](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#microsoft-azure-blob-storage) + +```tf +module "restic" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/restic/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + repository = "azure:container-name:/workspace-backups" + password = var.restic_password + + env = { + AZURE_ACCOUNT_NAME = var.azure_account_name + AZURE_ACCOUNT_KEY = var.azure_account_key + } +} +``` + +### Google Cloud Storage + +[Official Restic GCS Documentation](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#google-cloud-storage) + +```tf +module "restic" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/restic/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + repository = "gs:my-bucket:/workspace-backups" + password = var.restic_password + + env = { + GOOGLE_PROJECT_ID = var.gcp_project_id + GOOGLE_APPLICATION_CREDENTIALS = "/path/to/service-account.json" + } +} +``` + +### MinIO or S3-Compatible Storage + +[Official Restic Minio Documentation](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#minio-server) | [S3-Compatible](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#s3-compatible-storage) + +```tf +module "restic" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/restic/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + repository = "s3:http://minio.company.com:9000/workspace-backups" + password = var.restic_password + + env = { + AWS_ACCESS_KEY_ID = var.minio_access_key + AWS_SECRET_ACCESS_KEY = var.minio_secret_key + } +} +``` + +### SFTP + +[Official Restic SFTP Documentation](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#sftp) + +```tf +module "restic" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/restic/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + repository = "sftp:user@backup-server.com:/backups/restic" + password = var.restic_password + + # SSH key should be at ~/.ssh/id_rsa + # Or configure custom SSH command: + env = { + RESTIC_SFTP_COMMAND = "ssh user@host -i /path/to/key -s sftp" + } +} +``` + +### Local Directory (Testing) + +[Official Restic Local Documentation](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#local) + +```tf +module "restic" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/restic/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + repository = "/backup/restic-repo" + password = var.restic_password +} +``` + +**Note:** Use persistent storage (Docker volume, PV) for local repositories. + +## Advanced Configuration + +### Selective Backup Paths + +Only backup specific directories: + +```tf +module "restic" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/restic/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + repository = "s3:s3.amazonaws.com/backups" + password = var.restic_password + + backup_paths = [ + "/home/coder/projects", + "/home/coder/.config", + "/home/coder/data", + ] + + exclude_patterns = [ + "**/.git", + "**/node_modules", + "**/__pycache__", + "**/target", + "**/.venv", + "**/tmp", + ] + + env = { + AWS_ACCESS_KEY_ID = var.aws_access_key + AWS_SECRET_ACCESS_KEY = var.aws_secret_key + } +} +``` + +### Periodic Backups While Running + +Backup every N minutes while workspace is active: + +```tf +module "restic" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/restic/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + repository = "b2:workspace-backups" + password = var.restic_password + + # Backup every 30 minutes while workspace is running + backup_interval_minutes = 30 + + env = { + B2_ACCOUNT_ID = var.b2_account_id + B2_ACCOUNT_KEY = var.b2_account_key + } +} +``` + +### Custom Stop Script + +Run cleanup before backup: + +```tf +module "restic" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/restic/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + repository = "s3:s3.amazonaws.com/backups" + password = var.restic_password + + custom_stop_script = <<-EOF + #!/bin/bash + echo "Cleaning up before backup..." + rm -rf /tmp/* + docker system prune -f + find /home/coder -name "*.log" -delete + EOF + + env = { + AWS_ACCESS_KEY_ID = var.aws_access_key + AWS_SECRET_ACCESS_KEY = var.aws_secret_key + } +} +``` + +### Clone Another Workspace's Backup + +Restore from a specific snapshot: + +```tf +module "restic" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/restic/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + repository = "s3:s3.amazonaws.com/backups" + password = var.restic_password + + # Restore from specific snapshot (find ID using: restic snapshots) + restore_on_start = true + snapshot_id = "abc123def" # The snapshot ID to restore + + env = { + AWS_ACCESS_KEY_ID = var.aws_access_key + AWS_SECRET_ACCESS_KEY = var.aws_secret_key + } +} +``` + +To find snapshot IDs from another workspace: + +```bash +# List all snapshots grouped by workspace +restic snapshots --group-by tags + +# Or filter by specific workspace +restic snapshots --tag workspace-owner:john --tag workspace-name:dev-workspace +``` + +### Custom Retention Policies + +Control how many backups to keep: + +```tf +module "restic" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/restic/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + repository = "s3:s3.amazonaws.com/backups" + password = var.restic_password + + # Keep last 10 backups + retention_keep_last = 10 + + # Keep daily backups for 14 days + retention_keep_daily = 14 + + # Keep weekly backups for 8 weeks + retention_keep_weekly = 8 + + # Keep monthly backups for 6 months + retention_keep_monthly = 6 + + # Apply retention automatically + auto_forget = true + + # Don't prune on stop (too slow) + auto_prune = false + + env = { + AWS_ACCESS_KEY_ID = var.aws_access_key + AWS_SECRET_ACCESS_KEY = var.aws_secret_key + } +} +``` + +### Using HCP Vault Secrets + +Store credentials securely: + +```tf +module "vault_secrets" { + source = "registry.coder.com/coder/hcp-vault-secrets/coder" + version = "1.0.34" + agent_id = coder_agent.main.id + app_name = "workspace-backups" + project_id = var.hcp_project_id + secrets = ["RESTIC_PASSWORD", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"] +} + +module "restic" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/restic/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + repository = "s3:s3.amazonaws.com/backups" + password = "" # Will use RESTIC_PASSWORD from vault + + depends_on = [module.vault_secrets] +} +``` + +## Manual Operations + +### Trigger Manual Backup + +Click the **"Backup Now"** button in the Coder UI, or run from terminal: + +```bash +restic-backup --tag manual-backup +``` + +### List Your Workspace's Backups + +```bash +restic snapshots --tag workspace-id:$RESTIC_WORKSPACE_ID +``` + +Or view all snapshots: + +```bash +restic snapshots +``` + +### List All Workspace Backups in Repository + +```bash +restic snapshots --group-by tags +``` + +This shows snapshots grouped by workspace, making it easy to see all workspace backups in the repository. + +### Restore Specific Snapshot + +```bash +# List snapshots for this workspace +restic snapshots --tag workspace-id:$RESTIC_WORKSPACE_ID + +# Restore to temporary location for inspection +restic restore /tmp/restore < snapshot-id > --target + +# Or restore to original location +restic restore / < snapshot-id > --target +``` + +### Check Repository Health + +```bash +restic check +``` + +### Manual Cleanup + +```bash +# Remove old snapshots for this workspace +restic forget --tag workspace-id:$RESTIC_WORKSPACE_ID --keep-last 3 + +# Reclaim space (removes unreferenced data) +restic prune +``` + +## Important Considerations + +### Stop Backup Limitations + +> **Warning**: The `backup_on_stop` feature may not work on all template types if the agent is terminated before backup completes. See [coder/coder#6174](https://github.com/coder/coder/issues/6174) for details. + +**Recommendations**: + +- Test stop backups with your specific template +- Keep backups fast (use selective paths and exclusions) +- Use `backup_interval_minutes` for important data +- Set `auto_prune = false` for stop backups (prune is slow) + +### Repository Organization + +**Single Shared Repository** (Recommended): + +- All workspaces share one repository +- Backups are tagged with workspace metadata +- Deduplication saves space +- Easy credential management + +**Per-Workspace Repositories**: + +- Each workspace uses separate repository +- More isolation but more complex +- No cross-workspace restore + +### Security + +- Repository password encrypts ALL backups +- Use Coder parameters or external secrets for credentials +- Backend credentials should have minimal permissions +- Consider separate repositories for different teams + +### Performance Tips + +- **Use exclusions**: Skip `.git`, `node_modules`, caches +- **Selective paths**: Only backup what you need +- **Interval backups**: Balance frequency vs performance +- **Retention policies**: Keep low retention to save storage costs +- **Prune manually**: Don't enable `auto_prune` on stop (too slow) + +## Troubleshooting + +### Backup Fails on Stop + +The workspace might be terminating before backup completes. Try: + +- Reducing backup size with selective paths +- Using interval backups instead +- Testing with a local repository first + +### Restore Blocks Login Too Long + +- Reduce restore size with selective backup paths +- Set `start_blocks_login = false` to allow login during restore +- Use faster storage backend + +### Repository Not Found + +Ensure: + +- Repository URL is correct +- Backend credentials are valid +- Network connectivity to storage backend +- Repository has been initialized (`auto_init_repo = true`) + +### Permission Denied + +Check: + +- Backend credentials have write permissions +- Local directory (if used) is writable +- SSH key (for SFTP) is accessible + +### Out of Storage Space + +Run cleanup: + +```bash +restic forget --tag workspace-id:$RESTIC_WORKSPACE_ID --keep-last 2 +restic prune +``` + +## Links + +- [Restic Documentation](https://restic.readthedocs.io/) +- [Restic GitHub](https://github.com/restic/restic) +- [Coder Documentation](https://coder.com/docs) diff --git a/registry/coder/modules/restic/main.test.ts b/registry/coder/modules/restic/main.test.ts new file mode 100644 index 000000000..0453b8681 --- /dev/null +++ b/registry/coder/modules/restic/main.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "bun:test"; +import { + executeScriptInContainer, + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +describe("restic", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + agent_id: "test-agent-id", + repository: "s3:s3.amazonaws.com/test-bucket", + password: "test-password", + }); + + it("installs restic successfully", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + repository: "/tmp/restic-repo", + password: "test-password", + install_restic: "true", + auto_init_repo: "false", + restore_on_start: "false", + }); + + const output = await executeScriptInContainer( + state, + "alpine", + "sh", + "apk add --no-cache curl bzip2", + ); + + if (output.exitCode !== 0) { + console.log("Exit code:", output.exitCode); + console.log("STDOUT:", output.stdout.join("\n")); + console.log("STDERR:", output.stderr.join("\n")); + } + + expect(output.exitCode).toBe(0); + const stdout = output.stdout.join("\n"); + expect(stdout).toContain("Restic Backup Module Setup"); + expect(stdout).toContain("Installing Restic..."); + expect(stdout).toContain("Detected OS: linux"); + expect(stdout).toContain("Architecture:"); + expect(stdout).toContain("Fetching latest version"); + expect(stdout).toContain("Version:"); + expect(stdout).toContain("Downloading Restic"); + expect(stdout).toContain("Restic installed:"); + expect(stdout).toContain("Restic verified:"); + expect(stdout).toContain("restic"); + expect(stdout).toContain("Restic setup complete"); + }); + + it("creates backup helper script in workspace", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent", + repository: "/tmp/restic-repo", + password: "test-password", + install_restic: "false", + auto_init_repo: "false", + restore_on_start: "false", + }); + + const output = await executeScriptInContainer(state, "alpine"); + + const stdout = output.stdout.join("\n"); + + expect(stdout).toContain("Installing backup helper script"); + expect(stdout).toContain("Backup helper installed:"); + expect(stdout).toContain("/restic-backup"); + expect(stdout).toContain("Backup helper verified as executable"); + }); +}); diff --git a/registry/coder/modules/restic/main.tf b/registry/coder/modules/restic/main.tf new file mode 100644 index 000000000..cd88cb45c --- /dev/null +++ b/registry/coder/modules/restic/main.tf @@ -0,0 +1,271 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 0.12" + } + } +} + +data "coder_workspace" "me" {} + +data "coder_workspace_owner" "me" {} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "repository" { + type = string + description = "Restic repository location (e.g., 's3:s3.amazonaws.com/bucket', 'b2:bucket-name', '/local/path')." +} + +variable "password" { + type = string + description = "Password for encrypting the Restic repository. Keep this secure!" + sensitive = true +} + +variable "install_restic" { + type = bool + description = "Whether to install Restic binary." + default = true +} + +variable "restic_version" { + type = string + description = "Version of Restic to install (e.g., '0.16.4' or 'latest')." + default = "latest" +} + +variable "backup_paths" { + type = list(string) + description = "List of paths to backup. Can be absolute or relative to 'directory'." + default = ["/home/coder"] +} + +variable "exclude_patterns" { + type = list(string) + description = "Patterns to exclude from backup (e.g., ['**/.git', '**/node_modules'])." + default = [] +} + +variable "backup_tags" { + type = list(string) + description = "Additional tags to apply to all snapshots." + default = [] +} + +variable "directory" { + type = string + description = "Working directory for backup operations." + default = "~" +} + +variable "backup_on_stop" { + type = bool + description = "Whether to automatically backup when workspace stops." + default = true +} + +variable "backup_interval_minutes" { + type = number + description = "Backup every N minutes while workspace is running (0 = disabled)." + default = 0 +} + +variable "restore_on_start" { + type = bool + description = "Whether to restore from backup when workspace starts." + default = true +} + +variable "snapshot_id" { + type = string + description = "Specific snapshot ID to restore. If empty and restore_on_start is true, restores latest backup of this workspace. If set, restores that specific snapshot (useful for cloning workspaces)." + default = "" +} + +variable "restore_target" { + type = string + description = "Target directory for restore ('/' restores to original paths)." + default = "/" +} + +variable "start_blocks_login" { + type = bool + description = "Whether to block login until restore completes." + default = true +} + +variable "custom_stop_script" { + type = string + description = "Custom script to run before stop backup." + default = "" +} + +variable "retention_keep_last" { + type = number + description = "Keep last N snapshots per workspace." + default = 10 +} + +variable "retention_keep_daily" { + type = number + description = "Keep daily snapshots for N days." + default = 14 +} + +variable "retention_keep_weekly" { + type = number + description = "Keep weekly snapshots for N weeks." + default = 8 +} + +variable "retention_keep_monthly" { + type = number + description = "Keep monthly snapshots for N months." + default = 6 +} + +variable "auto_forget" { + type = bool + description = "Apply retention policies automatically after backup." + default = false +} + +variable "auto_prune" { + type = bool + description = "Run prune after forget to reclaim space (slower but frees storage)." + default = false +} + +variable "auto_init_repo" { + type = bool + description = "Automatically initialize repository if it doesn't exist." + default = true +} + +variable "env" { + type = map(string) + description = "Environment variables for backend configuration (e.g., AWS_ACCESS_KEY_ID, B2_ACCOUNT_KEY). See README for backend-specific examples." + default = {} + sensitive = true +} + +variable "icon" { + type = string + description = "Icon to use for Restic apps." + default = "/icon/restic.svg" +} + +variable "order" { + type = number + description = "Order of apps in UI." + default = null +} + +variable "group" { + type = string + description = "Group name for apps." + default = null +} + +resource "coder_env" "restic_repository" { + agent_id = var.agent_id + name = "RESTIC_REPOSITORY" + value = var.repository +} + +resource "coder_env" "restic_password" { + agent_id = var.agent_id + name = "RESTIC_PASSWORD" + value = var.password +} + +resource "coder_env" "backend_env" { + for_each = nonsensitive(var.env) + agent_id = var.agent_id + name = each.key + value = each.value +} + +resource "coder_env" "workspace_owner" { + agent_id = var.agent_id + name = "RESTIC_WORKSPACE_OWNER" + value = data.coder_workspace_owner.me.name +} + +resource "coder_env" "workspace_name" { + agent_id = var.agent_id + name = "RESTIC_WORKSPACE_NAME" + value = data.coder_workspace.me.name +} + +resource "coder_env" "workspace_id" { + agent_id = var.agent_id + name = "RESTIC_WORKSPACE_ID" + value = data.coder_workspace.me.id +} + +resource "coder_script" "install_and_restore" { + agent_id = var.agent_id + display_name = "Restic Setup" + icon = var.icon + run_on_start = true + start_blocks_login = var.restore_on_start && var.start_blocks_login + + script = templatefile("${path.module}/scripts/run.sh", { + INSTALL_RESTIC = var.install_restic + RESTIC_VERSION = var.restic_version + AUTO_INIT = var.auto_init_repo + RESTORE_ON_START = var.restore_on_start + SNAPSHOT_ID = var.snapshot_id + RESTORE_TARGET = var.restore_target + BACKUP_INTERVAL = var.backup_interval_minutes + BACKUP_PATHS = jsonencode(var.backup_paths) + EXCLUDE_PATTERNS = jsonencode(var.exclude_patterns) + BACKUP_TAGS = jsonencode(var.backup_tags) + DIRECTORY = var.directory + RETENTION_LAST = var.retention_keep_last + RETENTION_DAILY = var.retention_keep_daily + RETENTION_WEEKLY = var.retention_keep_weekly + RETENTION_MONTHLY = var.retention_keep_monthly + AUTO_FORGET = var.auto_forget + AUTO_PRUNE = var.auto_prune + BACKUP_SCRIPT_B64 = base64encode(file("${path.module}/scripts/backup.sh")) + }) +} + +resource "coder_script" "stop_backup" { + count = var.backup_on_stop ? 1 : 0 + agent_id = var.agent_id + display_name = "Restic Backup" + icon = var.icon + run_on_stop = true + start_blocks_login = false + + script = <<-EOT + #!/usr/bin/env bash + set -euo pipefail + + ${var.custom_stop_script} + + "$CODER_SCRIPT_BIN_DIR/restic-backup" --tag "stop-backup" + EOT +} + +resource "coder_app" "restic_backup" { + agent_id = var.agent_id + slug = "restic-backup" + display_name = "Backup Now" + icon = var.icon + order = var.order + group = var.group + + command = "$CODER_SCRIPT_BIN_DIR/restic-backup --tag manual-backup" +} + diff --git a/registry/coder/modules/restic/restic.tftest.hcl b/registry/coder/modules/restic/restic.tftest.hcl new file mode 100644 index 000000000..823ad455b --- /dev/null +++ b/registry/coder/modules/restic/restic.tftest.hcl @@ -0,0 +1,333 @@ +run "required_variables" { + command = plan + + variables { + agent_id = "test-agent" + repository = "s3:s3.amazonaws.com/test-bucket" + password = "test-password" + } +} + +run "stop_backup_script_created_when_enabled" { + command = plan + + variables { + agent_id = "test-agent" + repository = "/tmp/restic-repo" + password = "test-password" + backup_on_stop = true + } + + assert { + condition = coder_script.stop_backup[0].run_on_stop == true + error_message = "Stop backup script should have run_on_stop enabled" + } + + assert { + condition = coder_script.stop_backup[0].agent_id == "test-agent" + error_message = "Stop backup script should use correct agent_id" + } +} + +run "stop_backup_script_not_created_when_disabled" { + command = plan + + variables { + agent_id = "test-agent" + repository = "/tmp/restic-repo" + password = "test-password" + backup_on_stop = false + } + + assert { + condition = length(coder_script.stop_backup) == 0 + error_message = "Stop backup script should not be created when backup_on_stop is false" + } +} + +run "restore_blocks_login_by_default" { + command = plan + + variables { + agent_id = "test-agent" + repository = "/tmp/restic-repo" + password = "test-password" + restore_on_start = true + } + + assert { + condition = coder_script.install_and_restore.start_blocks_login == true + error_message = "Install script should block login when restore_on_start and start_blocks_login are true" + } +} + +run "restore_does_not_block_login_when_disabled" { + command = plan + + variables { + agent_id = "test-agent" + repository = "/tmp/restic-repo" + password = "test-password" + restore_on_start = true + start_blocks_login = false + } + + assert { + condition = coder_script.install_and_restore.start_blocks_login == false + error_message = "Install script should not block login when start_blocks_login is false" + } +} + +run "workspace_metadata_env_vars_created" { + command = plan + + variables { + agent_id = "test-agent" + repository = "/tmp/restic-repo" + password = "test-password" + } + + assert { + condition = coder_env.workspace_owner.name == "RESTIC_WORKSPACE_OWNER" + error_message = "Workspace owner env var should be RESTIC_WORKSPACE_OWNER" + } + + assert { + condition = coder_env.workspace_name.name == "RESTIC_WORKSPACE_NAME" + error_message = "Workspace name env var should be RESTIC_WORKSPACE_NAME" + } + + assert { + condition = coder_env.workspace_id.name == "RESTIC_WORKSPACE_ID" + error_message = "Workspace ID env var should be RESTIC_WORKSPACE_ID" + } +} + +run "core_env_vars_created" { + command = plan + + variables { + agent_id = "test-agent" + repository = "s3:s3.amazonaws.com/bucket" + password = "secure-password" + } + + assert { + condition = coder_env.restic_repository.name == "RESTIC_REPOSITORY" + error_message = "Repository env var should be RESTIC_REPOSITORY" + } + + assert { + condition = coder_env.restic_repository.value == "s3:s3.amazonaws.com/bucket" + error_message = "Repository env var should match input" + } + + assert { + condition = coder_env.restic_password.name == "RESTIC_PASSWORD" + error_message = "Password env var should be RESTIC_PASSWORD" + } +} + +run "safe_retention_defaults" { + command = plan + + variables { + agent_id = "test-agent" + repository = "/tmp/restic-repo" + password = "test-password" + } + + # Verify auto_forget is false by default (safe) + assert { + condition = var.auto_forget == false + error_message = "auto_forget should be false by default for safety" + } + + # Verify reasonable retention defaults + assert { + condition = var.retention_keep_last == 10 + error_message = "Default retention_keep_last should be 10" + } + + assert { + condition = var.retention_keep_daily == 14 + error_message = "Default retention_keep_daily should be 14" + } +} + +run "manual_backup_app_created" { + command = plan + + variables { + agent_id = "test-agent" + repository = "/tmp/restic-repo" + password = "test-password" + } + + assert { + condition = coder_app.restic_backup.slug == "restic-backup" + error_message = "Backup app should have slug restic-backup" + } + + assert { + condition = coder_app.restic_backup.display_name == "Backup Now" + error_message = "Backup app should display 'Backup Now'" + } + + assert { + condition = can(regex("restic-backup", coder_app.restic_backup.command)) + error_message = "Backup app command should call restic-backup helper" + } +} + +run "install_restic_enabled_in_script" { + command = plan + + variables { + agent_id = "test-agent" + repository = "/tmp/restic-repo" + password = "test-password" + install_restic = true + } + + assert { + condition = can(regex("INSTALL_RESTIC=\"true\"", coder_script.install_and_restore.script)) + error_message = "Script should have INSTALL_RESTIC set to true" + } +} + +run "install_restic_disabled_in_script" { + command = plan + + variables { + agent_id = "test-agent" + repository = "/tmp/restic-repo" + password = "test-password" + install_restic = false + } + + assert { + condition = can(regex("INSTALL_RESTIC=\"false\"", coder_script.install_and_restore.script)) + error_message = "Script should have INSTALL_RESTIC set to false" + } +} + +run "auto_init_repo_configuration" { + command = plan + + variables { + agent_id = "test-agent" + repository = "/tmp/restic-repo" + password = "test-password" + auto_init_repo = false + } + + assert { + condition = can(regex("AUTO_INIT=\"false\"", coder_script.install_and_restore.script)) + error_message = "Script should have AUTO_INIT set to false" + } +} + +run "restore_on_start_configuration" { + command = plan + + variables { + agent_id = "test-agent" + repository = "/tmp/restic-repo" + password = "test-password" + restore_on_start = true + snapshot_id = "abc123" + } + + assert { + condition = can(regex("RESTORE_ON_START=\"true\"", coder_script.install_and_restore.script)) + error_message = "Script should have RESTORE_ON_START set to true" + } + + assert { + condition = can(regex("SNAPSHOT_ID=\"abc123\"", coder_script.install_and_restore.script)) + error_message = "Script should have SNAPSHOT_ID set to abc123" + } +} + +run "interval_backup_configuration" { + command = plan + + variables { + agent_id = "test-agent" + repository = "/tmp/restic-repo" + password = "test-password" + backup_interval_minutes = 30 + } + + assert { + condition = can(regex("BACKUP_INTERVAL=\"30\"", coder_script.install_and_restore.script)) + error_message = "Script should have BACKUP_INTERVAL set to 30" + } +} + +run "interval_backup_disabled_by_default" { + command = plan + + variables { + agent_id = "test-agent" + repository = "/tmp/restic-repo" + password = "test-password" + } + + assert { + condition = can(regex("BACKUP_INTERVAL=\"0\"", coder_script.install_and_restore.script)) + error_message = "Script should have BACKUP_INTERVAL set to 0 by default" + } +} + +run "backup_paths_and_exclusions_configuration" { + command = plan + + variables { + agent_id = "test-agent" + repository = "/tmp/restic-repo" + password = "test-password" + backup_paths = ["/home/coder", "/workspace"] + exclude_patterns = ["*.log", "node_modules"] + backup_tags = ["production", "daily"] + } + + assert { + condition = can(regex("/home/coder", coder_script.install_and_restore.script)) + error_message = "Script should contain backup path /home/coder" + } + + assert { + condition = can(regex("/workspace", coder_script.install_and_restore.script)) + error_message = "Script should contain backup path /workspace" + } + + assert { + condition = can(regex("\\*.log", coder_script.install_and_restore.script)) + error_message = "Script should contain exclude pattern *.log" + } + + assert { + condition = can(regex("production", coder_script.install_and_restore.script)) + error_message = "Script should contain backup tag production" + } +} + +run "custom_stop_script_included" { + command = plan + + variables { + agent_id = "test-agent" + repository = "/tmp/restic-repo" + password = "test-password" + backup_on_stop = true + custom_stop_script = "echo 'Pre-backup cleanup'" + } + + assert { + condition = can(regex("echo 'Pre-backup cleanup'", coder_script.stop_backup[0].script)) + error_message = "Stop script should contain custom stop script" + } +} + diff --git a/registry/coder/modules/restic/scripts/backup.sh b/registry/coder/modules/restic/scripts/backup.sh new file mode 100644 index 000000000..6326e574d --- /dev/null +++ b/registry/coder/modules/restic/scripts/backup.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +set -euo pipefail + +CONF_FILE="$CODER_SCRIPT_DATA_DIR/restic-backup.conf" +if [ -f "$CONF_FILE" ]; then + # shellcheck source=/dev/null + source "$CONF_FILE" +else + echo "Error: Configuration file not found: $CONF_FILE" >&2 + exit 1 +fi + +EXTRA_TAGS=() +while [[ $# -gt 0 ]]; do + case "$1" in + --tag) + EXTRA_TAGS+=("$2") + shift 2 + ;; + *) + echo "Unknown argument: $1" >&2 + echo "Usage: restic-backup [--tag TAG]" >&2 + exit 1 + ;; + esac +done + +echo "--------------------------------" +echo "Restic Backup" +echo "--------------------------------" + +DIRECTORY="${DIRECTORY/#\~/$HOME}" + +PATHS=$(echo "$BACKUP_PATHS" | python3 -c "import json, sys; print(' '.join(json.load(sys.stdin)))" 2> /dev/null || echo ".") +EXCLUDES=$(echo "$EXCLUDE_PATTERNS" | python3 -c "import json, sys; [print(f'--exclude={p}') for p in json.load(sys.stdin)]" 2> /dev/null || echo "") +TAGS=$(echo "$BACKUP_TAGS" | python3 -c "import json, sys; [print(f'--tag={t}') for t in json.load(sys.stdin)]" 2> /dev/null || echo "") + +TAG_ARGS=( + "--tag=workspace-id:$RESTIC_WORKSPACE_ID" + "--tag=workspace-owner:$RESTIC_WORKSPACE_OWNER" + "--tag=workspace-name:$RESTIC_WORKSPACE_NAME" +) + +if [ -n "$TAGS" ]; then + while IFS= read -r tag; do + [ -n "$tag" ] && TAG_ARGS+=("$tag") + done <<< "$TAGS" +fi + +for tag in "${EXTRA_TAGS[@]}"; do + TAG_ARGS+=("--tag=$tag") +done + +EXCLUDE_ARGS=() +if [ -n "$EXCLUDES" ]; then + while IFS= read -r exclude; do + [ -n "$exclude" ] && EXCLUDE_ARGS+=("$exclude") + done <<< "$EXCLUDES" +fi + +cd "$DIRECTORY" || { + echo "Error: Failed to change to directory: $DIRECTORY" >&2 + exit 1 +} + +echo "Working directory: $(pwd)" +echo "Backup paths: $PATHS" +echo "Tags: ${TAG_ARGS[*]}" +[ ${#EXCLUDE_ARGS[@]} -gt 0 ] && echo "Exclusions: ${EXCLUDE_ARGS[*]}" +echo "Starting backup..." + +# shellcheck disable=SC2086 +if restic backup $PATHS "${TAG_ARGS[@]}" "${EXCLUDE_ARGS[@]}"; then + echo "Backup completed successfully" +else + echo "Error: Backup failed" >&2 + exit 1 +fi + +if [ "$AUTO_FORGET" = "true" ]; then + echo "Applying retention policies..." + + FORGET_ARGS=( + "--tag=workspace-id:$RESTIC_WORKSPACE_ID" + "--keep-last=$RETENTION_LAST" + ) + + [ "$RETENTION_DAILY" -gt 0 ] && FORGET_ARGS+=("--keep-daily=$RETENTION_DAILY") + [ "$RETENTION_WEEKLY" -gt 0 ] && FORGET_ARGS+=("--keep-weekly=$RETENTION_WEEKLY") + [ "$RETENTION_MONTHLY" -gt 0 ] && FORGET_ARGS+=("--keep-monthly=$RETENTION_MONTHLY") + + if [ "$AUTO_PRUNE" = "true" ]; then + FORGET_ARGS+=("--prune") + echo "Pruning unreferenced data..." + fi + + if restic forget "${FORGET_ARGS[@]}"; then + echo "Retention policies applied" + else + echo "Warning: Failed to apply retention policies" >&2 + fi +fi + +echo "Backup process complete" diff --git a/registry/coder/modules/restic/scripts/run.sh b/registry/coder/modules/restic/scripts/run.sh new file mode 100644 index 000000000..5a2b44376 --- /dev/null +++ b/registry/coder/modules/restic/scripts/run.sh @@ -0,0 +1,296 @@ +#!/usr/bin/env bash +set -euo pipefail + +: $${CODER_SCRIPT_BIN_DIR:=$HOME/.local/bin} +: $${CODER_SCRIPT_DATA_DIR:=$HOME/.local/share/coder} + +mkdir -p "$CODER_SCRIPT_BIN_DIR" +mkdir -p "$CODER_SCRIPT_DATA_DIR" + +export PATH="$HOME/.local/bin:$PATH" +INSTALL_RESTIC="${INSTALL_RESTIC}" +RESTIC_VERSION="${RESTIC_VERSION}" +AUTO_INIT="${AUTO_INIT}" +RESTORE_ON_START="${RESTORE_ON_START}" +SNAPSHOT_ID="${SNAPSHOT_ID}" +RESTORE_TARGET="${RESTORE_TARGET}" +BACKUP_INTERVAL="${BACKUP_INTERVAL}" +BACKUP_PATHS='${BACKUP_PATHS}' +EXCLUDE_PATTERNS='${EXCLUDE_PATTERNS}' +BACKUP_TAGS='${BACKUP_TAGS}' +DIRECTORY="${DIRECTORY}" +RETENTION_LAST="${RETENTION_LAST}" +RETENTION_DAILY="${RETENTION_DAILY}" +RETENTION_WEEKLY="${RETENTION_WEEKLY}" +RETENTION_MONTHLY="${RETENTION_MONTHLY}" +AUTO_FORGET="${AUTO_FORGET}" +AUTO_PRUNE="${AUTO_PRUNE}" +BACKUP_SCRIPT_B64='${BACKUP_SCRIPT_B64}' + +echo "--------------------------------" +echo "Restic Backup Module Setup" +echo "--------------------------------" + +detect_os_arch() { + OS=$(uname -s | tr '[:upper:]' '[:lower:]') + ARCH=$(uname -m) + + case "$ARCH" in + x86_64) + ARCH="amd64" + ;; + aarch64 | arm64) + ARCH="arm64" + ;; + armv7l) + ARCH="arm" + ;; + *) + echo "Unsupported architecture: $ARCH" + exit 1 + ;; + esac + + case "$OS" in + linux | darwin) ;; + *) + echo "Unsupported OS: $OS" + exit 1 + ;; + esac + + echo "Detected OS: $OS, Architecture: $ARCH" +} + +install_restic() { + if [ "$INSTALL_RESTIC" != "true" ]; then + echo "Skipping Restic installation (install_restic=false)" + return + fi + + if command -v restic > /dev/null 2>&1; then + INSTALLED_VERSION=$(restic version | head -n1 | awk '{print $2}') + echo "Restic already installed: $INSTALLED_VERSION" + + if [ "$RESTIC_VERSION" != "latest" ] && [ "$INSTALLED_VERSION" != "$RESTIC_VERSION" ]; then + echo "Warning: Version mismatch (installed: $INSTALLED_VERSION, requested: $RESTIC_VERSION)" + fi + return + fi + + echo "Installing Restic..." + + detect_os_arch + + if [ "$RESTIC_VERSION" = "latest" ]; then + echo "Fetching latest version..." + LATEST_VERSION=$(curl -fsSL https://api.github.com/repos/restic/restic/releases/latest | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\1/') + + if [ -z "$LATEST_VERSION" ]; then + echo "Error: Failed to fetch latest version" + exit 1 + fi + + echo "Version: $LATEST_VERSION" + DOWNLOAD_URL="https://github.com/restic/restic/releases/download/v$${LATEST_VERSION}/restic_$${LATEST_VERSION}_$${OS}_$${ARCH}.bz2" + else + DOWNLOAD_URL="https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/restic_${RESTIC_VERSION}_$${OS}_$${ARCH}.bz2" + fi + + echo "Downloading Restic..." + + mkdir -p "$HOME/.local/bin" + + TMP_FILE=$(mktemp) + if curl -fsSL "$DOWNLOAD_URL" -o "$TMP_FILE"; then + bunzip2 -c "$TMP_FILE" > "$HOME/.local/bin/restic" + chmod +x "$HOME/.local/bin/restic" + rm "$TMP_FILE" + echo "Restic installed: $($HOME/.local/bin/restic version)" + else + echo "Error: Download failed" + rm -f "$TMP_FILE" + exit 1 + fi +} + +verify_installation() { + if ! command -v restic > /dev/null 2>&1; then + echo "Error: restic command not found in PATH" + echo "PATH: $PATH" + + if [ "$INSTALL_RESTIC" = "true" ]; then + exit 1 + else + echo "Warning: restic not found but install_restic=false, continuing anyway" + return + fi + fi + + echo "Restic verified: $(restic version | head -n1)" +} + +init_repository() { + if [ "$AUTO_INIT" != "true" ]; then + echo "Skipping repository initialization (auto_init_repo=false)" + return + fi + + echo "Checking repository..." + + if restic snapshots > /dev/null 2>&1; then + echo "Repository already initialized" + return + fi + + echo "Initializing repository..." + if restic init; then + echo "Repository initialized" + else + echo "Error: Failed to initialize repository" + exit 1 + fi +} + +install_backup_helper() { + echo "Installing backup helper script..." + + HELPER_SCRIPT="$CODER_SCRIPT_BIN_DIR/restic-backup" + + echo -n "$BACKUP_SCRIPT_B64" | base64 -d > "$HELPER_SCRIPT" + chmod +x "$HELPER_SCRIPT" + + cat > "$CODER_SCRIPT_DATA_DIR/restic-backup.conf" << EOF +BACKUP_PATHS='$BACKUP_PATHS' +EXCLUDE_PATTERNS='$EXCLUDE_PATTERNS' +BACKUP_TAGS='$BACKUP_TAGS' +DIRECTORY='$DIRECTORY' +RETENTION_LAST='$RETENTION_LAST' +RETENTION_DAILY='$RETENTION_DAILY' +RETENTION_WEEKLY='$RETENTION_WEEKLY' +RETENTION_MONTHLY='$RETENTION_MONTHLY' +AUTO_FORGET='$AUTO_FORGET' +AUTO_PRUNE='$AUTO_PRUNE' +EOF + + if [ ! -x "$HELPER_SCRIPT" ]; then + echo "Error: Backup helper is not executable" + exit 1 + fi + + echo "Backup helper installed: $HELPER_SCRIPT" + echo "Backup helper verified as executable" +} + +find_latest_snapshot() { + local TAG_FILTER="$1" + + SNAPSHOTS_JSON=$(restic snapshots --tag "$TAG_FILTER" --json 2> /dev/null || echo "[]") + + LATEST_SNAPSHOT=$(echo "$SNAPSHOTS_JSON" | python3 -c " +import json, sys +snapshots = json.load(sys.stdin) +if snapshots: + latest = max(snapshots, key=lambda s: s['time']) + print(latest['short_id']) +else: + print('') +" 2> /dev/null || echo "") + + echo "$LATEST_SNAPSHOT" +} + +restore_on_start() { + if [ "$RESTORE_ON_START" != "true" ]; then + echo "Skipping restore (restore_on_start=false)" + return + fi + + echo "--------------------------------" + echo "Restore Configuration" + echo "--------------------------------" + + SNAPSHOT_TO_RESTORE="" + + if [ -n "$SNAPSHOT_ID" ]; then + echo "Restoring specific snapshot: $SNAPSHOT_ID" + SNAPSHOT_TO_RESTORE="$SNAPSHOT_ID" + else + echo "Finding latest backup for this workspace..." + SNAPSHOT_TO_RESTORE=$(find_latest_snapshot "workspace-id:$RESTIC_WORKSPACE_ID") + + if [ -z "$SNAPSHOT_TO_RESTORE" ]; then + echo "No previous backup found" + echo "Starting with fresh workspace" + return + fi + + echo "Found snapshot: $SNAPSHOT_TO_RESTORE" + fi + + echo "Restoring to $RESTORE_TARGET..." + + if restic restore "$SNAPSHOT_TO_RESTORE" --target "$RESTORE_TARGET"; then + echo "Restore completed successfully" + else + echo "Error: Restore failed" + exit 1 + fi +} + +setup_interval_backup() { + if [ "$BACKUP_INTERVAL" -eq 0 ]; then + return + fi + + echo "Setting up interval backup (every $BACKUP_INTERVAL minutes)..." + + cat > "$CODER_SCRIPT_DATA_DIR/interval-backup.sh" << 'EOFSCRIPT' +#!/usr/bin/env bash +set -euo pipefail + +INTERVAL_MINUTES="$1" +INTERVAL_SECONDS=$((INTERVAL_MINUTES * 60)) + +echo "Starting interval backup loop (every $INTERVAL_MINUTES minutes)" + +while true; do + sleep "$INTERVAL_SECONDS" + + echo "Running scheduled backup..." + if "$CODER_SCRIPT_BIN_DIR/restic-backup" --tag "interval-backup"; then + echo "Scheduled backup completed" + else + echo "Scheduled backup failed" + fi +done +EOFSCRIPT + + chmod +x "$CODER_SCRIPT_DATA_DIR/interval-backup.sh" + + nohup "$CODER_SCRIPT_DATA_DIR/interval-backup.sh" "$BACKUP_INTERVAL" \ + >> "$CODER_SCRIPT_DATA_DIR/interval-backup.log" 2>&1 & + + echo "Interval backup started in background (PID: $!)" +} + +main() { + install_restic + verify_installation + init_repository + install_backup_helper + restore_on_start + setup_interval_backup + + echo "--------------------------------" + echo "Restic setup complete" + echo "--------------------------------" + echo "Available commands:" + echo " restic-backup - Run manual backup" + echo " restic snapshots - List all snapshots" + echo " restic restore - Restore specific snapshot" + echo "" + echo "Repository: $${RESTIC_REPOSITORY:-not set}" +} + +main