Compare commits
4 Commits
main
...
coder-labs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2920f0517f | ||
|
|
98c1767ffb | ||
|
|
d6a96c3351 | ||
|
|
9085b30390 |
100
registry/coder-labs/modules/gcp-disk-snapshot/README.md
Normal file
100
registry/coder-labs/modules/gcp-disk-snapshot/README.md
Normal file
@ -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`
|
||||||
80
registry/coder-labs/modules/gcp-disk-snapshot/main.test.ts
Normal file
80
registry/coder-labs/modules/gcp-disk-snapshot/main.test.ts
Normal file
@ -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",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
178
registry/coder-labs/modules/gcp-disk-snapshot/main.tf
Normal file
178
registry/coder-labs/modules/gcp-disk-snapshot/main.tf
Normal file
@ -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
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user