#!/usr/bin/env bash
# scripts/site-headend-install.sh [--check|--dry-run] --compose-cidr <CIDR> [--maas-url-ip <IP>]
#
# Installs a VR1 SITE HEADEND on the host it runs on: MAAS (region+rack) + LXD, with that
# LXD registered back into MAAS as an LXD VM host so MAAS can COMPOSE the site's non-stack
# service machines into it. This is D-114, and it is VR0's proven `lxd` + `tailscale`
# pattern applied per site.
#
# RUN IT ON THE SITE HOST (voffice1 today; vdc1/vdc2 later), as root:
#     ssh <site-host> 'sudo bash -s' -- --compose-cidr 10.10.1.0/24 < scripts/site-headend-install.sh
#
#   --check     report what is installed/configured; mutate nothing; exit 0 ok / 1 incomplete
#   --dry-run   print what it would do; mutate nothing
#   (no flag)   install + configure (idempotent; safe to re-run)
#
# EXIT: 0 ok | 1 check-failed | 2 bad args | 3 unsupported OS | 4 install failed. ASCII + LF.
#
# =====================================================================================
# THE FOUR TRAPS THIS SCRIPT EXISTS TO ENCODE (all hit for real on 2026-07-13; do not
# "simplify" any of them away):
#
# 1. `lxd init --auto` FAILS OUTRIGHT on a host that also runs MAAS.
#    It creates lxdbr0 with LXD's own DHCP+DNS ON, which starts a dnsmasq; dnsmasq binds
#    the WILDCARD 0.0.0.0:53, and MAAS's bind9 (`named`) already holds :53. Verbatim:
#      Failed starting network: The DNS and DHCP service exited prematurely: exit status 2
#      ("dnsmasq: failed to create listening socket for 10.10.1.1: Address already in use")
#    Note the message names the BRIDGE address, which sends you hunting the wrong thing --
#    the real conflict is the wildcard bind. So: NEVER `lxd init --auto` here. Create the
#    bridge explicitly with DHCP/DNS off AND `raw.dnsmasq=port=0`, which stops dnsmasq
#    binding :53 at all. (`ipv4.dhcp=false` + `dns.mode=none` alone are NOT enough -- LXD
#    still spawns dnsmasq for a managed bridge, and it still tries to bind :53.)
#
# 2. `lxc` READS ITS CONFIG FROM STDIN. If you pipe this script to `bash -s` over ssh, the
#    FIRST `lxc` call with no stdin redirect EATS THE REST OF THE SCRIPT as YAML and dies
#    with `yaml: unmarshal errors`, silently truncating everything after it. Every `lxc`
#    call below therefore ends in `</dev/null`. Same class as the OPNsense tcsh trap.
#
# 3. LXD SNAP TRACK CHANGES ARE ONE-WAY -- you cannot downgrade a track. LXD MUST be
#    installed DIRECTLY onto 5.21/stable and never allowed to land on `latest` first,
#    because MAAS 3.6/3.7 is INCOMPATIBLE with LXD >= 6.7 (LXD 6.7 consolidated API
#    endpoints; MAAS's pinned pylxd 2.3.5 cannot speak to them; fixed in pylxd >= 2.3.9,
#    not yet in a MAAS release). Canonical's guidance: LXD <= 6.6, or the 5.21 LTS track.
#    https://discourse.maas.io/t/maas-incompatibility-with-lxd-6-7/15749
#    Bonus: `core.trust_password` still EXISTS in 5.21 and is GONE in 6.x -- the pin also
#    preserves the scriptable registration path.
#
# 4. MAAS DHCP IS ENABLED ON THE COMPOSE NETWORK ONLY -- NEVER on the site LAN. The site
#    LAN already has an authoritative DHCP server (Kea, on the OPNsense edge). Two DHCP
#    servers on one L2 is an intermittent failure that is genuinely unpleasant to
#    diagnose. --compose-cidr is REQUIRED and has no default precisely so this can never
#    be picked by accident, and the script REFUSES to touch DHCP on any other subnet.
# =====================================================================================
set -uo pipefail

CHECK=0; DRYRUN=0; COMPOSE_CIDR=""; MAAS_IP=""
while [ $# -gt 0 ]; do
  case "$1" in
    --check)         CHECK=1 ;;
    --dry-run)       DRYRUN=1 ;;
    --compose-cidr)  shift; COMPOSE_CIDR="${1:-}" ;;
    --maas-url-ip)   shift; MAAS_IP="${1:-}" ;;
    -h|--help)
      echo "usage: site-headend-install.sh [--check|--dry-run] --compose-cidr <CIDR> [--maas-url-ip <IP>]"
      exit 0 ;;
    *) echo "FAIL: unknown arg '$1'" >&2; exit 2 ;;
  esac
  shift
done

[ -n "$COMPOSE_CIDR" ] || { echo "FAIL: --compose-cidr is REQUIRED (e.g. 10.10.1.0/24). It has no default on purpose: MAAS must serve DHCP on the compose network ONLY, never on the site LAN, which the edge's Kea already owns." >&2; exit 2; }
echo "$COMPOSE_CIDR" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.0/24$' || { echo "FAIL: --compose-cidr must be a /24 like 10.10.1.0/24 (got '$COMPOSE_CIDR')" >&2; exit 2; }

BASE="${COMPOSE_CIDR%.0/24}"           # 10.10.1
BRIDGE_IP="${BASE}.1"
RANGE_LO="${BASE}.100"
RANGE_HI="${BASE}.200"
SECRETS=/root/maas-secrets
MAAS_CHANNEL="3.7/stable"
PG_CHANNEL="16/stable"
LXD_CHANNEL="5.21/stable"             # TRAP 3 -- do not change without reading it

run() { # run <desc> -- <cmd...>
  local d="$1"; shift; [ "${1:-}" = "--" ] && shift
  if [ "$DRYRUN" = "1" ]; then printf '  [dry-run] %s\n            $ %s\n' "$d" "$*"; return 0; fi
  echo "  -> $d"; "$@"
}
have() { command -v "$1" >/dev/null 2>&1; }

# ---------------- check mode (read-only) ----------------
report() {
  local bad=0
  snap list maas       >/dev/null 2>&1 && echo "OK: maas snap $(snap list maas 2>/dev/null | awk 'NR>1{print $2" ("$4")"}')"     || { echo "ABSENT: maas snap"; bad=1; }
  snap list postgresql >/dev/null 2>&1 && echo "OK: postgresql $(snap list postgresql 2>/dev/null | awk 'NR>1{print $2}')"       || { echo "ABSENT: postgresql snap"; bad=1; }
  if snap list lxd >/dev/null 2>&1; then
    local tr; tr="$(snap list lxd 2>/dev/null | awk 'NR>1{print $4}')"
    if [ "$tr" = "$LXD_CHANNEL" ]; then echo "OK: lxd on $tr"
    else echo "WRONG TRACK: lxd is on '$tr', must be '$LXD_CHANNEL' (MAAS breaks on LXD >= 6.7; track changes are ONE-WAY -- a reinstall is needed)"; bad=1; fi
  else echo "ABSENT: lxd snap"; bad=1; fi

  if have lxc && lxc network show lxdbr0 </dev/null >/dev/null 2>&1; then
    echo "OK: lxdbr0 exists (ipv4.dhcp=$(lxc network get lxdbr0 ipv4.dhcp </dev/null 2>/dev/null), dns.mode=$(lxc network get lxdbr0 dns.mode </dev/null 2>/dev/null))"
    local n53 n67
    n53=$(ss -lunp 2>/dev/null | grep dnsmasq | grep -c ':53 ')
    n67=$(ss -lunp 2>/dev/null | grep dnsmasq | grep -c ':67 ')
    if [ "${n53:-0}" = "0" ] && [ "${n67:-0}" = "0" ]; then
      echo "OK: dnsmasq binds NO :53 and NO :67 -- MAAS and the edge's Kea remain sole DHCP/DNS authorities"
    else
      echo "FAIL: dnsmasq holds :53=$n53 :67=$n67 -- it is FIGHTING MAAS/Kea (see trap 1)"; bad=1
    fi
  else echo "ABSENT: lxdbr0"; bad=1; fi

  if have maas && maas status 2>/dev/null | grep -q regiond; then echo "OK: MAAS initialised"; else echo "ABSENT: MAAS not initialised"; bad=1; fi
  return "$bad"
}

report_st=0
if [ "$CHECK" = "1" ]; then report; exit $?; fi
report >/dev/null 2>&1; report_st=$?
[ "$report_st" = "0" ] && [ "$DRYRUN" = "0" ] && { echo "-- site headend already installed and configured --"; exit 0; }

have apt-get || { echo "FAIL: Debian/Ubuntu (apt) only." >&2; exit 3; }
# root is required to MUTATE, not to plan: --dry-run stays runnable by anyone (and by the harness).
[ "$DRYRUN" = "1" ] || [ "$(id -u)" = "0" ] || { echo "FAIL: must run as root (use: ssh <host> 'sudo bash -s' -- ... < $0)" >&2; exit 4; }
[ -n "$MAAS_IP" ] || MAAS_IP="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{print $7; exit}')"
[ -n "$MAAS_IP" ] || { echo "FAIL: could not measure this host's outbound IP; pass --maas-url-ip" >&2; exit 4; }

install -d -m 0700 "$SECRETS"
newpass() { # newpass <file> -- generate once, 0600, NEVER echoed
  [ -s "$1" ] && return 0
  openssl rand -base64 30 | tr -dc 'A-Za-z0-9' | head -c 28 > "$1"; chmod 0600 "$1"
}

echo "== 1. time: MAAS manages time via chrony; systemd-timesyncd conflicts =="
run "disable systemd-timesyncd" -- systemctl disable --now systemd-timesyncd >/dev/null 2>&1

echo "== 2. MAAS $MAAS_CHANNEL + PostgreSQL $PG_CHANNEL =="
snap list maas       >/dev/null 2>&1 || run "install maas"       -- snap install maas --channel="$MAAS_CHANNEL"       || exit 4
snap list postgresql >/dev/null 2>&1 || run "install postgresql" -- snap install postgresql --channel="$PG_CHANNEL"   || exit 4

echo "== 3. MAAS database (password generated here, 0600, never printed) =="
if [ "$DRYRUN" = "0" ]; then
  newpass "$SECRETS/db.pass"; DBPASS="$(cat "$SECRETS/db.pass")"
  postgresql.psql -U postgres -h /tmp -tAc "SELECT 1 FROM pg_roles WHERE rolname='maas'" 2>/dev/null | grep -q 1 \
    || postgresql.psql -U postgres -h /tmp -c "CREATE ROLE maas LOGIN PASSWORD '$DBPASS'" >/dev/null 2>&1
  postgresql.psql -U postgres -h /tmp -tAc "SELECT 1 FROM pg_database WHERE datname='maas'" 2>/dev/null | grep -q 1 \
    || postgresql.createdb -U postgres -h /tmp -O maas maas >/dev/null 2>&1
  echo "  -> role + database 'maas' present"
else
  echo "  [dry-run] create role+db 'maas' with a generated password"
fi

echo "== 4. maas init region+rack =="
if [ "$DRYRUN" = "0" ]; then
  if maas status 2>/dev/null | grep -q regiond; then
    echo "  -> already initialised"
  else
    maas init region+rack --maas-url "http://${MAAS_IP}:5240/MAAS" \
      --database-uri "postgres://maas:$(cat "$SECRETS/db.pass")@localhost/maas" --force >/dev/null 2>&1 \
      || { echo "FAIL: maas init" >&2; exit 4; }
    echo "  -> initialised"
  fi
  newpass "$SECRETS/admin.pass"
  maas apikey --username admin >/dev/null 2>&1 || \
    maas createadmin --username admin --password "$(cat "$SECRETS/admin.pass")" --email "admin@$(hostname)" >/dev/null 2>&1
  maas apikey --username admin > "$SECRETS/admin.apikey" 2>/dev/null; chmod 0600 "$SECRETS/admin.apikey"
  for i in $(seq 1 60); do curl -sf -o /dev/null --max-time 3 "http://${MAAS_IP}:5240/MAAS/" && break; sleep 5; done
  maas login admin "http://${MAAS_IP}:5240/MAAS" "$(cat "$SECRETS/admin.apikey")" >/dev/null 2>&1
  echo "  -> admin ready, CLI logged in"
else
  echo "  [dry-run] maas init region+rack + createadmin + login"
fi

echo "== 5. LXD, DIRECTLY onto $LXD_CHANNEL (trap 3: track changes are ONE-WAY) =="
if snap list lxd >/dev/null 2>&1; then
  tr="$(snap list lxd 2>/dev/null | awk 'NR>1{print $4}')"
  [ "$tr" = "$LXD_CHANNEL" ] || { echo "FAIL: lxd already on track '$tr'; you CANNOT downgrade a snap track. Remove it (snap remove lxd) and re-run." >&2; exit 4; }
else
  run "install lxd" -- snap install lxd --channel="$LXD_CHANNEL" || exit 4
  run "hold lxd auto-refresh" -- snap refresh --hold lxd >/dev/null 2>&1
fi
[ "$DRYRUN" = "0" ] && lxd waitready --timeout=90

echo "== 6. LXD storage + compose bridge (built explicitly; see trap 1 -- the auto-init path FAILS here) =="
if [ "$DRYRUN" = "0" ]; then
  # dir driver: conservative inside an already-nested guest (no zfs loop file).
  lxc storage show default </dev/null >/dev/null 2>&1 || lxc storage create default dir </dev/null >/dev/null 2>&1
  if ! lxc network show lxdbr0 </dev/null >/dev/null 2>&1; then
    lxc network create lxdbr0 \
      ipv4.address="${BRIDGE_IP}/24" ipv4.nat=true ipv4.dhcp=false \
      ipv6.address=none dns.mode=none raw.dnsmasq="port=0" </dev/null >/dev/null 2>&1 \
      || { echo "FAIL: could not create lxdbr0 (see trap 1)" >&2; exit 4; }
  fi
  lxc profile device remove default eth0 </dev/null >/dev/null 2>&1
  lxc profile device add default root disk path=/ pool=default </dev/null >/dev/null 2>&1
  lxc profile device add default eth0 nic network=lxdbr0 name=eth0 </dev/null >/dev/null 2>&1
  lxc config set core.https_address '[::]:8443' </dev/null
  newpass "$SECRETS/lxd-trust.pass"
  lxc config set core.trust_password "$(cat "$SECRETS/lxd-trust.pass")" </dev/null
  echo "  -> lxdbr0 ${BRIDGE_IP}/24, NAT on, LXD DHCP/DNS OFF; API on :8443"

  n53=$(ss -lunp 2>/dev/null | grep dnsmasq | grep -c ':53 ')
  n67=$(ss -lunp 2>/dev/null | grep dnsmasq | grep -c ':67 ')
  [ "${n53:-0}" = "0" ] && [ "${n67:-0}" = "0" ] \
    || { echo "FAIL: dnsmasq is holding :53=$n53 :67=$n67 -- it will fight MAAS/Kea (trap 1)" >&2; exit 4; }
  echo "  -> VERIFIED: dnsmasq binds no :53 and no :67"
else
  echo "  [dry-run] create dir pool + lxdbr0 ${BRIDGE_IP}/24 (dhcp off, dns off, raw.dnsmasq=port=0), expose API"
fi

echo "== 7. register LXD into MAAS as a VM host =="
if [ "$DRYRUN" = "0" ]; then
  if maas admin vm-hosts read 2>/dev/null | grep -q '"type": "lxd"'; then
    echo "  -> LXD vm-host already registered"
  else
    maas admin vm-hosts create type=lxd name="$(hostname)-lxd" \
      power_address="https://${MAAS_IP}:8443" project=default \
      password="$(cat "$SECRETS/lxd-trust.pass")" >/dev/null 2>&1 \
      && echo "  -> registered as $(hostname)-lxd" || { echo "FAIL: vm-hosts create" >&2; exit 4; }
  fi
else
  echo "  [dry-run] maas admin vm-hosts create type=lxd power_address=https://${MAAS_IP}:8443"
fi

echo "== 8. MAAS DHCP on the COMPOSE network ONLY (trap 4) =="
if [ "$DRYRUN" = "0" ]; then
  SUB_JSON="$(maas admin subnets read 2>/dev/null)"
  SUB_ID="$(printf '%s' "$SUB_JSON" | python3 -c "
import sys,json
t=sys.argv[1]
for s in json.load(sys.stdin):
    if s.get('cidr')==t: print(s['id']); break
" "$COMPOSE_CIDR")"
  [ -n "$SUB_ID" ] || { echo "FAIL: MAAS has not discovered $COMPOSE_CIDR (is lxdbr0 up?)" >&2; exit 4; }
  FAB_ID="$(printf '%s' "$SUB_JSON" | python3 -c "
import sys,json
t=sys.argv[1]
for s in json.load(sys.stdin):
    if s.get('cidr')==t: print(s['vlan']['fabric_id']); break
" "$COMPOSE_CIDR")"
  RACK="$(maas admin rack-controllers read 2>/dev/null | python3 -c "import sys,json;print(json.load(sys.stdin)[0]['system_id'])")"

  # GATEWAY: MAAS auto-discovers the compose subnet from lxdbr0's interface, and that discovery
  # carries NO gateway. Without this, a DEPLOYED machine gets a netplan with NO DEFAULT ROUTE --
  # it comes up looking fine (DNS even resolves, because MAAS's bind9 is link-local) but has NO
  # EGRESS, so the first `apt install` on it hangs. lxdbr0 IS the gateway (it does the NAT).
  # Measured 2026-07-13: office1-netbox deployed with gateway_ip=None and had to be redeployed.
  maas admin subnet update "$SUB_ID" gateway_ip="$BRIDGE_IP" >/dev/null 2>&1 \
    && echo "  -> gateway_ip=$BRIDGE_IP set on $COMPOSE_CIDR"

  maas admin ipranges create type=dynamic subnet="$SUB_ID" start_ip="$RANGE_LO" end_ip="$RANGE_HI" \
    comment="MAAS DHCP/PXE for LXD-composed VMs (D-114)" >/dev/null 2>&1
  maas admin vlan update "$FAB_ID" 0 dhcp_on=True primary_rack="$RACK" >/dev/null 2>&1 \
    && echo "  -> DHCP ON for $COMPOSE_CIDR ($RANGE_LO-$RANGE_HI), rack=$RACK"

  # HARD GUARD: assert MAAS did not turn DHCP on for any OTHER subnet.
  BADSUB="$(maas admin subnets read 2>/dev/null | python3 -c "
import sys,json
t=sys.argv[1]
bad=[s['cidr'] for s in json.load(sys.stdin) if s['vlan'].get('dhcp_on') and s.get('cidr')!=t]
print(','.join(bad))
" "$COMPOSE_CIDR")"
  if [ -n "$BADSUB" ]; then
    echo "FAIL: MAAS DHCP is ON for a subnet that is NOT the compose network: $BADSUB" >&2
    echo "      The site LAN's DHCP belongs to the edge's Kea. Turn this OFF before proceeding." >&2
    exit 4
  fi
  echo "  -> VERIFIED: MAAS DHCP is on for $COMPOSE_CIDR and NO other subnet"
else
  echo "  [dry-run] iprange $RANGE_LO-$RANGE_HI + dhcp_on for $COMPOSE_CIDR only"
fi

[ "$DRYRUN" = "1" ] && { echo "  (dry-run: no changes made)"; exit 0; }
echo
echo "INSTALLED. MAAS UI: http://${MAAS_IP}:5240/MAAS  (admin password: $SECRETS/admin.pass, 0600)"
echo "Compose a service VM with:"
echo "  maas admin vm-host compose <id> cores=2 memory=4096 storage=root:40 hostname=<name>"
exit 0
