#!/usr/bin/env bash
# scripts/opnsense-plugins.sh [--dry-run] <list|apply> [profile]
#
# Install a NAMED OPNsense plugin PROFILE on an edge via the D-113 firmware API,
# the reusable tool D-129 pins for DC-edge bring-up (runbooks/dc-dc-phase2-tofu-
# dc-substrate.md, "Edge plugin profile"). It is a thin ORCHESTRATOR over
# scripts/opnsense-api.sh -- that script owns auth + secret handling (creds never
# in argv/context); this one owns the profile table + the install DISCIPLINE.
#
# THE DISCIPLINE (both learned the hard way 2026-07-18, D-129 Verification):
#   1. ONE PLUGIN AT A TIME + POLL. Two back-to-back core/firmware/install POSTs
#      collide on the OPNsense firmware LOCK -- the second fails SILENTLY. So we
#      install one, poll core/firmware/upgradestatus to completion, THEN the next.
#   2. os-qemu-guest-agent PACKAGE != a working guest agent. It also needs the
#      libvirt domain to expose the org.qemu.guest_agent.0 virtio channel (an IaC
#      job in modules/opnsense-edge) AND the service enabled AND an edge restart.
#      This script installs the package and LOUDLY says so; it cannot do the rest.
#
# Live use needs OPNSENSE_API_HOST (e.g. 10.10.0.1) + the API creds file that
# opnsense-api.sh reads. --dry-run prints the intended API calls and touches no
# network and reads no creds (so the harness can prove the shape offline).
#
# Exit: 0 ok | 1 usage/arg error | 2 an install did not converge.
set -euo pipefail

HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
API="$HERE/opnsense-api.sh"

# Poll bounds for the firmware action (live only). Generous: a plugin pull over a
# slow lab uplink can take a while; we would rather wait than declare a false fail.
: "${PLUGIN_POLL_MAX:=90}"     # iterations
: "${PLUGIN_POLL_SLEEP:=2}"    # seconds between polls

DRY_RUN=0
if [ "${1:-}" = "--dry-run" ]; then DRY_RUN=1; shift; fi
CMD="${1:-}"
PROFILE="${2:-}"

usage() {
  cat >&2 <<EOF
usage: opnsense-plugins.sh [--dry-run] <list|apply> [profile]
  list             print the known profiles and their plugins
  apply <profile>  install the profile's plugins, ONE AT A TIME + poll

profiles:
  vr1-edge     the VR1 (virtual) edge base profile -- D-129
  metal-edge   Roosevelt baremetal edge profile (site-specific, see notes)

  e.g. OPNSENSE_API_HOST=10.10.0.1 opnsense-plugins.sh apply vr1-edge
       opnsense-plugins.sh --dry-run apply vr1-edge
EOF
  exit 1
}

# --- Profile table (the single source of truth for "what is on an edge"). -------
# vr1-edge: adopted by D-129 2026-07-18. os-qemu-guest-agent = clean libvirt-guest
# coordination (needs the channel, see warning below); os-iperf = transit
# throughput validation.
profile_plugins() {
  case "$1" in
    vr1-edge)   echo "os-qemu-guest-agent os-iperf" ;;
    # metal-edge is DELIBERATELY not a flat list: os-nut vs os-apcupsd and the
    # intel-vs-amd microcode package are per-host choices (D-129). Emit the
    # unambiguous members; the operator adds the site-specific ones by hand.
    metal-edge) echo "os-smart os-lldpd" ;;
    *)          return 1 ;;
  esac
}

list_profiles() {
  local p
  for p in vr1-edge metal-edge; do
    printf '%-11s %s\n' "$p" "$(profile_plugins "$p")"
  done
  echo "metal-edge NOTE: also add os-nut|os-apcupsd + os-cpu-microcode-intel|-amd per host (D-129)."
}

# --- qemu-guest-agent honesty: the package is necessary but NOT sufficient. -----
qga_warning() {
  cat >&2 <<'EOF'
NOTE: os-qemu-guest-agent installs the PACKAGE only. For a WORKING guest agent the
      libvirt domain must expose the org.qemu.guest_agent.0 virtio channel (IaC:
      modules/opnsense-edge), the service must be enabled, and the edge restarted.
      New DC edges should be BORN with the channel; office1-opnsense retrofits at
      its next scheduled restart. (D-129 Verification, 2026-07-18.)
EOF
}

# DO NOT "simplify" this back to `bash "$API" ${DRY_RUN:+--dry-run} "$@"`.
# That was the original form and it was a FALSE-SUCCESS BUG (found 2026-07-20 on the
# first-ever live run, against the vr1-dc0 edge): `${VAR:+word}` expands whenever VAR
# is set and NON-EMPTY, and DRY_RUN=0 is non-empty -- so EVERY call, including a real
# `apply`, was silently handed --dry-run while the script still printed
# "OK: profile 'vr1-edge' applied". Nothing was ever installed. The harness missed it
# because it only ever drove --dry-run, so the live path had no coverage at all.
# Test the VALUE, never the emptiness.
api() {
  if [ "$DRY_RUN" = "1" ]; then
    bash "$API" --dry-run "$@"
  else
    bash "$API" "$@"
  fi
}

# Poll core/firmware/upgradestatus until the running action finishes. Live only;
# dry-run returns immediately (nothing was actually kicked off).
poll_until_done() {
  [ "$DRY_RUN" = "1" ] && { echo "DRY-RUN would poll core/firmware/upgradestatus"; return 0; }
  local i resp
  for i in $(seq 1 "$PLUGIN_POLL_MAX"); do
    resp="$(bash "$API" GET core/firmware/upgradestatus 2>/dev/null || true)"
    # status is "running" while the action is live; anything else = finished.
    printf '%s' "$resp" | grep -qE '"status"[[:space:]]*:[[:space:]]*"running"' || return 0
    sleep "$PLUGIN_POLL_SLEEP"
  done
  echo "FAIL: firmware action did not converge in $((PLUGIN_POLL_MAX*PLUGIN_POLL_SLEEP))s" >&2
  return 2
}

apply_profile() {
  local prof="$1" plugins pkg
  plugins="$(profile_plugins "$prof")" || { echo "FAIL: unknown profile '$prof'" >&2; usage; }

  case " $plugins " in *" os-qemu-guest-agent "*) qga_warning ;; esac

  for pkg in $plugins; do
    echo "== install $pkg =="
    api POST "core/firmware/install/$pkg"          # kick off (async on the edge)
    poll_until_done                                 # ... and WAIT before the next (the lock)
  done
  echo "OK: profile '$prof' applied ($plugins)"
}

case "$CMD" in
  list)  [ -n "$PROFILE" ] && usage; list_profiles ;;
  apply) [ -n "$PROFILE" ] || usage; apply_profile "$PROFILE" ;;
  *)     usage ;;
esac
