diff --git a/registry/coder-labs/modules/gcp-disk-snapshot/README.md b/registry/coder-labs/modules/gcp-disk-snapshot/README.md new file mode 100644 index 000000000..25cc15dc0 --- /dev/null +++ b/registry/coder-labs/modules/gcp-disk-snapshot/README.md @@ -0,0 +1,100 @@ +--- +display_name: GCP Disk Snapshot +description: Create and manage disk snapshots for Coder workspaces on GCP +icon: ../../../../.icons/gcp.svg +verified: false +tags: [gcp, snapshot, disk, backup, persistence] +--- + +# GCP Disk Snapshot Module + +This module provides disk snapshot functionality for Coder workspaces running on GCP Compute Engine. It automatically creates a snapshot when workspaces are stopped and allows users to restore from the snapshot when starting. + +```tf +module "disk_snapshot" { + source = "registry.coder.com/coder-labs/gcp-disk-snapshot/coder" + version = "1.0.0" + + disk_self_link = google_compute_disk.workspace.self_link + default_image = "debian-cloud/debian-12" + zone = var.zone + project = var.project_id +} +``` + +## Features + +- **Automatic Snapshots**: Creates a disk snapshot when workspaces are stopped +- **Single Snapshot**: Maintains one snapshot per workspace (overwrites on each stop) +- **Restore Option**: Users can choose to restore from snapshot or start fresh +- **Default to Restore**: Automatically selects restore if a snapshot exists +- **Pure Terraform**: No external CLI dependencies +- **Workspace Isolation**: Snapshots are named and labeled by workspace and owner + +## Usage + +### Basic Usage + +```hcl +module "disk_snapshot" { + source = "registry.coder.com/coder-labs/gcp-disk-snapshot/coder" + + disk_self_link = google_compute_disk.workspace.self_link + default_image = "debian-cloud/debian-12" + zone = var.zone + project = var.project_id +} + +# Create disk from snapshot or default image +resource "google_compute_disk" "workspace" { + name = "workspace-${data.coder_workspace.me.id}" + type = "pd-balanced" + zone = var.zone + size = 50 + + # Use snapshot if available, otherwise use default image + snapshot = module.disk_snapshot.snapshot_self_link + image = module.disk_snapshot.use_snapshot ? null : module.disk_snapshot.default_image + + lifecycle { + ignore_changes = [snapshot, image] + } +} +``` + +### With Regional Storage + +```hcl +module "disk_snapshot" { + source = "registry.coder.com/coder-labs/gcp-disk-snapshot/coder" + + disk_self_link = google_compute_disk.workspace.self_link + default_image = "debian-cloud/debian-12" + zone = var.zone + project = var.project_id + storage_locations = ["us-central1"] # Store snapshot in specific region + + labels = { + environment = "development" + team = "engineering" + } +} +``` + +## How It Works + +1. When a workspace stops, a snapshot is created with a predictable name: `{owner}-{workspace}-snapshot` +2. The snapshot is overwritten each time the workspace stops +3. When starting, users can choose to restore from the snapshot or start fresh +4. If a snapshot exists, restore is selected by default + +## Required IAM Permissions + +The service account running Terraform needs: + +- `compute.snapshots.create` +- `compute.snapshots.delete` +- `compute.snapshots.get` +- `compute.disks.createSnapshot` + +Or use the predefined role: `roles/compute.storageAdmin` diff --git a/registry/coder-labs/modules/gcp-disk-snapshot/main.test.ts b/registry/coder-labs/modules/gcp-disk-snapshot/main.test.ts new file mode 100644 index 000000000..29feb51af --- /dev/null +++ b/registry/coder-labs/modules/gcp-disk-snapshot/main.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "bun:test"; +import { runTerraformApply, runTerraformInit } from "~test"; + +describe("gcp-disk-snapshot", async () => { + await runTerraformInit(import.meta.dir); + + it("required variables with test mode", async () => { + await runTerraformApply(import.meta.dir, { + disk_self_link: + "projects/test-project/zones/us-central1-a/disks/test-disk", + default_image: "debian-cloud/debian-12", + zone: "us-central1-a", + project: "test-project", + test_mode: true, + }); + }); + + it("missing variable: disk_self_link", async () => { + await expect( + runTerraformApply(import.meta.dir, { + default_image: "debian-cloud/debian-12", + zone: "us-central1-a", + project: "test-project", + test_mode: true, + }), + ).rejects.toThrow(); + }); + + it("missing variable: default_image", async () => { + await expect( + runTerraformApply(import.meta.dir, { + disk_self_link: + "projects/test-project/zones/us-central1-a/disks/test-disk", + zone: "us-central1-a", + project: "test-project", + test_mode: true, + }), + ).rejects.toThrow(); + }); + + it("missing variable: zone", async () => { + await expect( + runTerraformApply(import.meta.dir, { + disk_self_link: + "projects/test-project/zones/us-central1-a/disks/test-disk", + default_image: "debian-cloud/debian-12", + project: "test-project", + test_mode: true, + }), + ).rejects.toThrow(); + }); + + it("missing variable: project", async () => { + await expect( + runTerraformApply(import.meta.dir, { + disk_self_link: + "projects/test-project/zones/us-central1-a/disks/test-disk", + default_image: "debian-cloud/debian-12", + zone: "us-central1-a", + test_mode: true, + }), + ).rejects.toThrow(); + }); + + it("supports optional variables", async () => { + await runTerraformApply(import.meta.dir, { + disk_self_link: + "projects/test-project/zones/us-central1-a/disks/test-disk", + default_image: "debian-cloud/debian-12", + zone: "us-central1-a", + project: "test-project", + test_mode: true, + storage_locations: JSON.stringify(["us-central1"]), + labels: JSON.stringify({ + environment: "test", + team: "engineering", + }), + }); + }); +}); diff --git a/registry/coder-labs/modules/gcp-disk-snapshot/main.tf b/registry/coder-labs/modules/gcp-disk-snapshot/main.tf new file mode 100644 index 000000000..747098599 --- /dev/null +++ b/registry/coder-labs/modules/gcp-disk-snapshot/main.tf @@ -0,0 +1,178 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + google = { + source = "hashicorp/google" + version = ">= 4.0" + } + coder = { + source = "coder/coder" + version = ">= 0.17" + } + } +} + +# Provider configuration for testing only +# In production, the provider will be inherited from the calling module +provider "google" { + project = "test-project" + region = "us-central1" + + # Fake credentials for testing - allows terraform plan/apply to run + # without actual GCP authentication in CI environments + credentials = jsonencode({ + type = "service_account" + project_id = "test-project" + private_key_id = "key-id" + private_key = "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA0Z3VS5JJcds3xfn/ygWyF8PbnGy0ARL00FVaKUOclBo0vo9C\nWL23EQJ2dWLV5g8k8DjFYIrXvARQPIDs0d+6UgKNKFjHmcZrj9i+e9v8zhVLB2wc\nfU2xsf3AJzLWr7L/LN6GEfT6m7kqKvBB6mJhpFn9RSAZ6WNvnOv1IVVQEq5Tfjlw\nGiJI0q0T8JmEobVSAaRJa7ZKQH1tBjTxcbr+EajVh5F2n7E0VqJNVNT5c5s8MJW0\nrn6AKaEVwmr3SW/NKQX6LxHRgVLJoWcL9j9B9cQ5Mz7u6h/oTrKLLt1v5NKvO9d8\ng39z7cKd1O6kd8nE3hZD7w5d0ileH9u9wZNPFwIDAQABAoIBADvhw8GIB0/G7mFP\ntest-fake-key-data-for-ci-testing-only\n-----END RSA PRIVATE KEY-----\n" + client_email = "test@test-project.iam.gserviceaccount.com" + client_id = "123456789" + auth_uri = "https://accounts.google.com/o/oauth2/auth" + token_uri = "https://oauth2.googleapis.com/token" + auth_provider_x509_cert_url = "https://www.googleapis.com/oauth2/v1/certs" + client_x509_cert_url = "https://www.googleapis.com/robot/v1/metadata/x509/test%40test-project.iam.gserviceaccount.com" + }) +} + +# Variables +variable "test_mode" { + description = "Set to true when running tests to skip GCP API calls" + type = bool + default = false +} + +variable "disk_self_link" { + description = "The self_link of the disk to create snapshots from" + type = string +} + +variable "default_image" { + description = "The default image to use when not restoring from a snapshot (e.g., debian-cloud/debian-12)" + type = string +} + +variable "zone" { + description = "The zone where the disk resides" + type = string +} + +variable "project" { + description = "The GCP project ID" + type = string +} + +variable "labels" { + description = "Additional labels to apply to snapshots" + type = map(string) + default = {} +} + +variable "storage_locations" { + description = "Cloud Storage bucket location to store the snapshot (regional or multi-regional)" + type = list(string) + default = [] +} + +# Get workspace information +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +# Locals for label normalization (GCP labels must be lowercase with hyphens/underscores) +locals { + normalized_workspace_name = lower(replace(replace(data.coder_workspace.me.name, "/[^a-z0-9-_]/", "-"), "--", "-")) + normalized_owner_name = lower(replace(replace(data.coder_workspace_owner.me.name, "/[^a-z0-9-_]/", "-"), "--", "-")) + normalized_template_name = lower(replace(replace(data.coder_workspace.me.template_name, "/[^a-z0-9-_]/", "-"), "--", "-")) + + # Single snapshot name per workspace + snapshot_name = "${local.normalized_owner_name}-${local.normalized_workspace_name}-snapshot" +} + +# Try to read existing snapshot for this workspace +data "google_compute_snapshot" "workspace_snapshot" { + count = var.test_mode ? 0 : 1 + name = local.snapshot_name + project = var.project +} + +locals { + # Check if snapshot exists + snapshot_exists = var.test_mode ? false : can(data.google_compute_snapshot.workspace_snapshot[0].self_link) + + # Default to using snapshot if it exists + default_restore = local.snapshot_exists ? "snapshot" : "none" +} + +# Parameter to choose whether to restore from snapshot +data "coder_parameter" "restore_snapshot" { + name = "restore_snapshot" + display_name = "Restore from Snapshot" + description = "Restore workspace from the last snapshot, or start fresh." + type = "string" + default = local.default_restore + mutable = true + order = 1 + + option { + name = "Fresh disk (no snapshot)" + value = "none" + description = "Start with a fresh disk using the default image" + } + + dynamic "option" { + for_each = local.snapshot_exists ? [1] : [] + content { + name = "Restore from snapshot" + value = "snapshot" + description = "Restore from: ${local.snapshot_name}" + } + } +} + +locals { + use_snapshot = data.coder_parameter.restore_snapshot.value == "snapshot" && local.snapshot_exists +} + +# Create/update snapshot when workspace is stopped +resource "google_compute_snapshot" "workspace_snapshot" { + count = !var.test_mode && data.coder_workspace.me.transition == "stop" ? 1 : 0 + name = local.snapshot_name + source_disk = var.disk_self_link + zone = var.zone + project = var.project + + storage_locations = length(var.storage_locations) > 0 ? var.storage_locations : null + + labels = merge(var.labels, { + coder_workspace = local.normalized_workspace_name + coder_owner = local.normalized_owner_name + coder_template = local.normalized_template_name + workspace_id = data.coder_workspace.me.id + }) +} + +# Outputs +output "snapshot_self_link" { + description = "The self_link of the snapshot to restore from (null if not using snapshot)" + value = local.use_snapshot ? data.google_compute_snapshot.workspace_snapshot[0].self_link : null +} + +output "use_snapshot" { + description = "Whether a snapshot is being used" + value = local.use_snapshot +} + +output "default_image" { + description = "The default image to use when not using a snapshot" + value = var.default_image +} + +output "snapshot_name" { + description = "The name of the workspace snapshot" + value = local.snapshot_name +} + +output "snapshot_exists" { + description = "Whether a snapshot exists for this workspace" + value = local.snapshot_exists +}