feat(staging): implement ephemeral staging VM provisioning and TrueNAS snapshot clone workflow
This commit is contained in:
parent
2386e1e942
commit
ac224b7294
4 changed files with 232 additions and 0 deletions
62
.forgejo/workflows/staging.yml
Normal file
62
.forgejo/workflows/staging.yml
Normal file
|
|
@ -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
|
||||||
35
scripts/truenas-staging-clone.sh
Executable file
35
scripts/truenas-staging-clone.sh
Executable file
|
|
@ -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."
|
||||||
39
scripts/truenas-staging-teardown.sh
Executable file
39
scripts/truenas-staging-teardown.sh
Executable file
|
|
@ -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."
|
||||||
96
terraform/staging-env/main.tf
Normal file
96
terraform/staging-env/main.tf
Normal file
|
|
@ -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."
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue