diff --git a/.forgejo/workflows/staging.yml b/.forgejo/workflows/staging.yml new file mode 100644 index 0000000..95c5e8b --- /dev/null +++ b/.forgejo/workflows/staging.yml @@ -0,0 +1,62 @@ +name: Dynamic Staging Environment + +on: + pull_request: + types: [opened, synchronize, closed] + +jobs: + manage-staging: + runs-on: self-hosted + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + + - name: Provision Staging Environment (Opened/Sync) + if: github.event.action == 'opened' || github.event.action == 'synchronize' + env: + PROXMOX_VE_ENDPOINT: "https://proxmox.local:8006/" + PROXMOX_VE_API_TOKEN: ${{ secrets.PROXMOX_TOKEN_SECRET }} + TF_VAR_pr_number: ${{ github.event.pull_request.number }} + # VM_ID could be dynamically generated or based on PR number (e.g., 8000 + PR_NUMBER) + TF_VAR_vm_id: ${{ format('8{0:03}', github.event.pull_request.number) }} + TF_VAR_staging_age_key: ${{ secrets.STAGING_AGE_KEY }} + + # TrueNAS integration (Secrets would need to be added to Forgejo) + TRUENAS_IP: "truenas.local" + TRUENAS_API_KEY: ${{ secrets.TRUENAS_API_KEY }} + POOL_NAME: "tank" + SOURCE_DATASET: "apps/production_data" + run: | + echo "Cloning datasets..." + # ./scripts/truenas-staging-clone.sh + + echo "Applying Terraform for PR ${{ github.event.pull_request.number }}..." + cd terraform/staging-env + terraform init + terraform apply -auto-approve + + - name: Teardown Staging Environment (Closed) + if: github.event.action == 'closed' + env: + PROXMOX_VE_ENDPOINT: "https://proxmox.local:8006/" + PROXMOX_VE_API_TOKEN: ${{ secrets.PROXMOX_TOKEN_SECRET }} + TF_VAR_pr_number: ${{ github.event.pull_request.number }} + TF_VAR_vm_id: ${{ format('8{0:03}', github.event.pull_request.number) }} + TF_VAR_staging_age_key: "" + + # TrueNAS integration + TRUENAS_IP: "truenas.local" + TRUENAS_API_KEY: ${{ secrets.TRUENAS_API_KEY }} + POOL_NAME: "tank" + SOURCE_DATASET: "apps/production_data" + run: | + echo "Destroying Terraform environment for PR ${{ github.event.pull_request.number }}..." + cd terraform/staging-env + terraform init + terraform destroy -auto-approve + + echo "Tearing down datasets..." + # ./scripts/truenas-staging-teardown.sh diff --git a/scripts/truenas-staging-clone.sh b/scripts/truenas-staging-clone.sh new file mode 100755 index 0000000..1659303 --- /dev/null +++ b/scripts/truenas-staging-clone.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +# This script creates a ZFS snapshot of a production dataset and clones it for a staging environment. +# Required environment variables: +# TRUENAS_IP: The IP address of the TrueNAS scale instance +# TRUENAS_API_KEY: The API token for TrueNAS +# POOL_NAME: The name of the ZFS pool (e.g., "tank") +# SOURCE_DATASET: The name of the production dataset (e.g., "apps/jellyfin") +# PR_NUMBER: The Pull Request number + +if [[ -z "${TRUENAS_IP:-}" || -z "${TRUENAS_API_KEY:-}" || -z "${POOL_NAME:-}" || -z "${SOURCE_DATASET:-}" || -z "${PR_NUMBER:-}" ]]; then + echo "Error: Missing required environment variables." + exit 1 +fi + +BASE_URL="http://${TRUENAS_IP}/api/v2.0" +HEADERS=( + "-H" "Authorization: Bearer ${TRUENAS_API_KEY}" + "-H" "Content-Type: application/json" +) + +DATASET_ID="${POOL_NAME}/${SOURCE_DATASET}" +SNAPSHOT_NAME="pr-${PR_NUMBER}-base" +CLONE_DATASET_NAME="${SOURCE_DATASET}-pr-${PR_NUMBER}" + +echo "1. Creating snapshot of ${DATASET_ID} @ ${SNAPSHOT_NAME}..." +curl -s -X POST "${BASE_URL}/zfs/snapshot" "${HEADERS[@]}" \ + -d "{\"dataset\": \"${DATASET_ID}\", \"name\": \"${SNAPSHOT_NAME}\"}" > /dev/null + +echo "2. Cloning snapshot to ${POOL_NAME}/${CLONE_DATASET_NAME}..." +curl -s -X POST "${BASE_URL}/zfs/snapshot/clone" "${HEADERS[@]}" \ + -d "{\"snapshot\": \"${DATASET_ID}@${SNAPSHOT_NAME}\", \"dataset_dst\": \"${CLONE_DATASET_NAME}\"}" > /dev/null + +echo "Staging dataset cloned successfully." diff --git a/scripts/truenas-staging-teardown.sh b/scripts/truenas-staging-teardown.sh new file mode 100755 index 0000000..9b68f26 --- /dev/null +++ b/scripts/truenas-staging-teardown.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +# This script destroys the ephemeral staging ZFS clone and the base snapshot. +# Required environment variables: +# TRUENAS_IP: The IP address of the TrueNAS scale instance +# TRUENAS_API_KEY: The API token for TrueNAS +# POOL_NAME: The name of the ZFS pool (e.g., "tank") +# SOURCE_DATASET: The name of the production dataset (e.g., "apps/jellyfin") +# PR_NUMBER: The Pull Request number + +if [[ -z "${TRUENAS_IP:-}" || -z "${TRUENAS_API_KEY:-}" || -z "${POOL_NAME:-}" || -z "${SOURCE_DATASET:-}" || -z "${PR_NUMBER:-}" ]]; then + echo "Error: Missing required environment variables." + exit 1 +fi + +BASE_URL="http://${TRUENAS_IP}/api/v2.0" +HEADERS=( + "-H" "Authorization: Bearer ${TRUENAS_API_KEY}" + "-H" "Content-Type: application/json" +) + +DATASET_ID="${POOL_NAME}/${SOURCE_DATASET}" +SNAPSHOT_NAME="pr-${PR_NUMBER}-base" +CLONE_DATASET_ID="${POOL_NAME}/${SOURCE_DATASET}-pr-${PR_NUMBER}" + +# TrueNAS API requires the ID to be URL-encoded for dataset deletion +# URL encoding helper (replace / with %2F) +ENCODED_CLONE_ID="${CLONE_DATASET_ID//\//%2F}" +ENCODED_SNAPSHOT_ID="${DATASET_ID}@${SNAPSHOT_NAME}" +ENCODED_SNAPSHOT_ID="${ENCODED_SNAPSHOT_ID//\//%2F}" + +echo "1. Destroying staging clone ${CLONE_DATASET_ID}..." +curl -s -X DELETE "${BASE_URL}/zfs/dataset/id/${ENCODED_CLONE_ID}" "${HEADERS[@]}" > /dev/null + +echo "2. Destroying base snapshot ${DATASET_ID}@${SNAPSHOT_NAME}..." +curl -s -X DELETE "${BASE_URL}/zfs/snapshot/id/${ENCODED_SNAPSHOT_ID}" "${HEADERS[@]}" > /dev/null + +echo "Staging dataset and snapshot cleaned up successfully." diff --git a/terraform/staging-env/main.tf b/terraform/staging-env/main.tf new file mode 100644 index 0000000..03faf1a --- /dev/null +++ b/terraform/staging-env/main.tf @@ -0,0 +1,96 @@ +terraform { + required_providers { + proxmox = { + source = "bpg/proxmox" + version = "~> 0.61.0" + } + } +} + +variable "vm_id" { + description = "The ID of the VM to create (should be unique per PR)" + type = number +} + +variable "pr_number" { + description = "The Pull Request number for this staging environment" + type = string +} + +variable "staging_age_key" { + description = "The private age key for decrypting staging secrets. Injected via Cloud-Init." + type = string + sensitive = true +} + +provider "proxmox" { + # Relies on PROXMOX_VE_ENDPOINT and PROXMOX_VE_API_TOKEN environment variables +} + +resource "proxmox_virtual_environment_vm" "staging_vm" { + name = "staging-pr-${var.pr_number}" + description = "Ephemeral staging environment for PR #${var.pr_number}" + node_name = "pve" + vm_id = var.vm_id + + # Clone from the latest golden image template + clone { + vm_id = 9000 + full = true + } + + agent { + enabled = true + } + + cpu { + cores = 2 + } + + memory { + dedicated = 2048 + } + + network_device { + bridge = "vmbr0" + # Assign a specific VLAN tag for staging isolation if configured on your switch + # vlan_id = 50 + } + + # Cloud-Init configuration to inject the staging age key and set up networking + initialization { + ip_config { + ipv4 { + address = "dhcp" + } + } + + user_data_file_id = proxmox_virtual_environment_file.cloud_config.id + } +} + +resource "proxmox_virtual_environment_file" "cloud_config" { + content_type = "snippets" + datastore_id = "local-zfs" + node_name = "pve" + + source_raw { + data = <<-EOF + #cloud-config + write_files: + - path: /var/lib/sops-nix/key.txt + permissions: '0600' + content: | + ${indent(10, var.staging_age_key)} + runcmd: + - echo "Staging age key injected successfully." + EOF + + file_name = "staging-pr-${var.pr_number}-cloud-init.yaml" + } +} + +output "staging_vm_ip" { + value = proxmox_virtual_environment_vm.staging_vm.ipv4_addresses[1][0] # Adjust index based on actual returned interfaces + description = "The IP address of the newly spun up staging VM." +}