From 2340c5a0e70cb4800f2373ee7740f15af60b611d Mon Sep 17 00:00:00 2001 From: Tibo De Peuter Date: Wed, 5 Aug 2026 18:13:15 +0200 Subject: [PATCH] feat(gitops): implement NATS JetStream and TrueNAS staging provisioning (Phase 5) --- .../services/hypervisor-gitops/default.nix | 54 ++++++++++++ opentofu/staging-env/main.tf | 19 ++++ scripts/staging-sync.sh | 87 +++++++++++++++++++ 3 files changed, 160 insertions(+) create mode 100644 scripts/staging-sync.sh diff --git a/nixos/modules/services/hypervisor-gitops/default.nix b/nixos/modules/services/hypervisor-gitops/default.nix index ac8f49f..2d9d05b 100644 --- a/nixos/modules/services/hypervisor-gitops/default.nix +++ b/nixos/modules/services/hypervisor-gitops/default.nix @@ -10,6 +10,36 @@ let runtimeInputs = with pkgs; [ git opentofu coreutils ]; text = builtins.readFile ../../../../scripts/hypervisor-sync.sh; }; + + stagingSyncScript = pkgs.writeShellApplication { + name = "staging-sync"; + runtimeInputs = with pkgs; [ opentofu coreutils jq curl ]; + text = builtins.readFile ../../../../scripts/staging-sync.sh; + }; + + natsConsumerScript = pkgs.writeShellApplication { + name = "nats-consumer"; + runtimeInputs = with pkgs; [ natscli jq stagingSyncScript ]; + text = '' + set -euo pipefail + + NATS_URL=''${NATS_URL:-"nats://192.168.0.20:4222"} + + echo "Starting NATS JetStream consumer for staging env..." + + # Try to create stream and consumer if they don't exist + nats --server "$NATS_URL" stream add FORGEJO_EVENTS --subjects "forgejo.staging" --ack --max-msgs=-1 --max-bytes=-1 --max-age=1y --storage file -f || true + nats --server "$NATS_URL" consumer add FORGEJO_EVENTS STAGING --pull --ack explicit --filter forgejo.staging --deliver all -f || true + + echo "Listening for messages..." + while true; do + # We use a simple sub to pull messages. In a real environment, + # a dedicated Go/Python client is better for manual explicit acks. + # This will auto-ack upon receipt and pass to the staging script. + nats --server "$NATS_URL" sub "forgejo.staging" | awk '/\[#.*\]/{flag=1; next} flag' | staging-sync || true + done + ''; + }; in { options.homelab.services.hypervisor-gitops = { enable = mkEnableOption "Hypervisor GitOps Service"; @@ -31,6 +61,8 @@ in { environment.systemPackages = with pkgs; [ git opentofu + natscli + jq ]; systemd.services.hypervisor-gitops = { @@ -60,5 +92,27 @@ in { Persistent = true; }; }; + + systemd.services.staging-sync = { + description = "Staging Environment NATS Consumer"; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + + serviceConfig = { + Type = "simple"; + User = "root"; # Needs root to read SOPS secrets + Restart = "always"; + RestartSec = "10s"; + + StateDirectory = "hypervisor-gitops"; + WorkingDirectory = "/var/lib/hypervisor-gitops"; + + # We assume TRUENAS_API_KEY is provided via a sops EnvironmentFile + # EnvironmentFile = config.sops.secrets."truenas-api-key".path; + + ExecStart = "${natsConsumerScript}/bin/nats-consumer"; + }; + }; }; } diff --git a/opentofu/staging-env/main.tf b/opentofu/staging-env/main.tf index b77ee4a..9538523 100644 --- a/opentofu/staging-env/main.tf +++ b/opentofu/staging-env/main.tf @@ -4,6 +4,10 @@ terraform { source = "bpg/proxmox" version = "~> 0.61.0" } + truenas = { + source = "deevus/truenas" + version = "~> 0.1.0" + } } } @@ -17,10 +21,25 @@ variable "pr_number" { type = string } +variable "truenas_snapshot_id" { + description = "The ID of the TrueNAS snapshot to clone" + type = string +} + provider "proxmox" { # Relies on PROXMOX_VE_ENDPOINT and PROXMOX_VE_API_TOKEN environment variables } +provider "truenas" { + # Relies on TRUENAS_API_KEY and TRUENAS_BASE_URL environment variables +} + +resource "truenas_dataset" "staging_clone" { + pool = "tank" + path = "production/staging-pr-${var.pr_number}" + snapshot_id = var.truenas_snapshot_id +} + resource "proxmox_virtual_environment_vm" "staging_vm" { name = "staging-pr-${var.pr_number}" description = "Ephemeral staging environment for PR #${var.pr_number}" diff --git a/scripts/staging-sync.sh b/scripts/staging-sync.sh new file mode 100644 index 0000000..eca15b5 --- /dev/null +++ b/scripts/staging-sync.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash + +# This script is triggered by the NATS JetStream consumer on the Control Center. +# It reads a Forgejo webhook JSON payload from STDIN and orchestrates the +# Staging VM OpenTofu lifecycle. + +set -euo pipefail + +# Read JSON payload from STDIN +PAYLOAD=$(cat) + +# Extract fields using jq +ACTION=$(echo "$PAYLOAD" | jq -r '.action // empty') +PR_NUMBER=$(echo "$PAYLOAD" | jq -r '.pull_request.number // empty') + +if [ -z "$ACTION" ] || [ -z "$PR_NUMBER" ] || [ "$PR_NUMBER" == "null" ]; then + printf "Invalid or missing action/pr_number in payload. Exiting.\n" + exit 0 +fi + +# We only care about PR events +if [[ "$ACTION" != "opened" && "$ACTION" != "reopened" && "$ACTION" != "synchronized" && "$ACTION" != "closed" ]]; then + printf "Ignoring PR action: %%s\n" "$ACTION" + exit 0 +fi + +# Ensure workspace directory exists for this PR +WORKSPACE="/var/lib/hypervisor-gitops/staging-pr-${PR_NUMBER}" +OPENTOFU_SRC="/var/lib/hypervisor-gitops/nix-config/opentofu/staging-env" + +# TrueNAS API variables +TRUENAS_URL=${TRUENAS_URL:-"https://192.168.0.11"} +TRUENAS_API_KEY=${TRUENAS_API_KEY:-""} +ZFS_DATASET=${ZFS_DATASET:-"tank/production"} + +get_latest_snapshot() { + # Fetch the latest snapshot for the dataset from TrueNAS API + # Assumes TRUENAS_API_KEY is exported in the environment by SOPS + curl -sS -k -X GET \ + -H "Authorization: Bearer ${TRUENAS_API_KEY}" \ + -H "Accept: application/json" \ + "${TRUENAS_URL}/api/v2.0/zfs/snapshot?id~=${ZFS_DATASET}%%25&limit=1&sort=-creation" | jq -r '.[0].id' +} + +printf "Processing PR #%%s (Action: %%s)\n" "$PR_NUMBER" "$ACTION" + +if [[ "$ACTION" == "closed" ]]; then + if [ ! -d "$WORKSPACE" ]; then + printf "Workspace %%s does not exist. Nothing to destroy.\n" "$WORKSPACE" + exit 0 + fi + printf "Destroying Staging Environment for PR #%%s...\n" "$PR_NUMBER" + cd "$WORKSPACE" + tofu destroy -var="pr_number=${PR_NUMBER}" -auto-approve + + # Cleanup + cd / + rm -rf "$WORKSPACE" + printf "Staging Environment Destroyed.\n" + exit 0 +fi + +# Provisioning (opened, reopened, synchronized) +printf "Setting up Staging Environment for PR #%%s...\n" "$PR_NUMBER" + +if [ ! -d "$WORKSPACE" ]; then + mkdir -p "$WORKSPACE" + cp -r "$OPENTOFU_SRC"/* "$WORKSPACE"/ +fi + +cd "$WORKSPACE" + +# Fetch latest TrueNAS snapshot dynamically +LATEST_SNAPSHOT=$(get_latest_snapshot) + +if [ -z "$LATEST_SNAPSHOT" ] || [ "$LATEST_SNAPSHOT" == "null" ]; then + printf "Failed to retrieve the latest TrueNAS snapshot. Aborting.\n" + exit 1 +fi + +printf "Latest Snapshot ID: %%s\n" "$LATEST_SNAPSHOT" + +# Apply OpenTofu +tofu init -upgrade +tofu apply -var="pr_number=${PR_NUMBER}" -var="truenas_snapshot_id=${LATEST_SNAPSHOT}" -auto-approve + +printf "Staging Environment Provisioned successfully.\n"