diff --git a/nixos/flake.nix b/nixos/flake.nix index bd6a5d1..53487fa 100644 --- a/nixos/flake.nix +++ b/nixos/flake.nix @@ -71,6 +71,7 @@ hosts = { izanagi.modules = [ ./hosts/izanagi ]; + Gitea.modules = [ ./hosts/Gitea ]; }; }; } diff --git a/nixos/modules/apps/gitea/default.nix b/nixos/modules/apps/gitea/default.nix index eeec8b6..0048728 100644 --- a/nixos/modules/apps/gitea/default.nix +++ b/nixos/modules/apps/gitea/default.nix @@ -21,6 +21,50 @@ in { options.homelab.apps.gitea.enable = lib.mkEnableOption "Gitea"; config = lib.mkIf cfg.enable { + services.nats = { + enable = true; + listenAddress = "0.0.0.0"; + port = 4222; + jetstream = true; + }; + + networking.firewall.allowedTCPPorts = [ 4222 9000 ]; + + systemd.services.gitea-webhook-bridge = { + description = "Gitea Webhook Bridge to NATS JetStream"; + after = [ "network.target" "nats.service" ]; + wants = [ "nats.service" ]; + wantedBy = [ "multi-user.target" ]; + path = with pkgs; [ natscli python3 ]; + script = '' + python3 -c ' +import http.server +import subprocess +import sys + +class WebhookHandler(http.server.BaseHTTPRequestHandler): + def do_POST(self): + content_length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(content_length) + try: + subprocess.run(["nats", "pub", "--server=nats://127.0.0.1:4222", "forgejo.staging"], input=body, check=True) + self.send_response(200) + self.end_headers() + self.wfile.write(b"OK\n") + except Exception as e: + self.send_response(500) + self.end_headers() + self.wfile.write(str(e).encode("utf-8")) + + def log_message(self, format, *args): + sys.stderr.write("%s - - [%s] %s\n" % (self.client_address[0], self.log_date_time_string(), format%args)) + +server = http.server.ThreadingHTTPServer(("0.0.0.0", 9000), WebhookHandler) +server.serve_forever() +' + ''; + }; + homelab = { users = { apps.enable = true; @@ -438,6 +482,7 @@ in { # ... oath2_client # ... webhook + FORGEJO__webhook__ALLOWED_HOST_LIST = "192.168.0.0/16,127.0.0.0/8,host.docker.internal,*"; FORGEJO__mailer__ENABLED = "true"; # Buffer length of channel, keep it as it is if you don't know what it is. 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"