#!/usr/bin/env bash
# scripts/dc-node-etchosts.sh -- per-DC cloudinit-userdata that gives every node a
# LOCAL /etc/hosts reverse mapping for its metal-internal address, so a charm that
# derives its TLS common_name from get_hostname(local_address) can resolve it even
# though the metal-internal plane is deliberately ISOLATED (no gateway, no route to
# the region resolver -- D-052). PROVEN CAUSE + FIX (2026-08-04, live):
# * charm-ovn-central (rev 311) computes cert CN = get_hostname(its metal-internal
# address); that address has NO reachable reverse-DNS resolver on the isolated
# plane -> get_hostname() returns None -> empty common_name -> vault issues no
# server cert -> ovn-central blocks "'certificates' awaiting server certificate
# data" and the OVN NB/SB cluster never forms (6641/6642 never listen).
# * MEASURED on the live dc0 model with a controlled test: adding
# `<metal-internal-ip> <hostname>` to /etc/hosts on ONE unit made get_hostname
# resolve, the charm published common_name, vault issued the per-unit server
# cert, and /etc/ovn/{cert_host,key_host,ovn-central.crt} were written -- while
# the two control units without the entry stayed broken. The entry is the sole
# cause of the fix.
#
# This is a NEW mechanism. It BORROWS the SHAPE of D-008's static-/etc/hosts
# bootstrap but is NOT that: D-008's /etc/hosts is FQDN->VIP for os-public-hostname
# (unset in VR1); this supplies each node's OWN metal-internal address->name reverse
# mapping. rdns_mode=2 and the MAAS forward record already exist; only the reverse
# is unreachable from the isolated plane, which the local files entry supplies.
# OVN does not check the server-cert CN content and vault signs any non-empty CN
# (2026-08-04 research), so the name value only has to be non-empty and stable.
#
# DELIVERY: juju model-config cloudinit-userdata (model-wide, applied at machine
# provision). It MUST be set BEFORE `juju deploy` (a gated pre-deploy step in
# runbooks/phase-01-bundle-deploy.md / dc-dc-phase4). Scoped to the metal-internal
# CIDR (pinned per DC from lib-net.sh) so metal-admin et al. keep their DNS reverse
# untouched -- blast radius is exactly the plane that lacks reverse DNS.
#
# Usage: dc-node-etchosts.sh <render|apply|check> <site>
# render print the cloudinit-userdata YAML (pure; no juju) -- what the harness grades
# apply set it on the DC's juju model (juju client; run on the DC rack, D-138)
# check read it back and verify the runcmd + this DC's metal-internal CIDR are present
# env: JUJU_MODEL (default: derived <site>, e.g. vr1-dc0) ; MODEL_ARG override
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MODE="${1:-}"; SITE="${2:-}"
case "$MODE" in render|apply|check) ;; *)
echo "usage: dc-node-etchosts.sh <render|apply|check> <site> e.g. dc-node-etchosts.sh render vr1-dc0" >&2; exit 2 ;;
esac
[ -n "$SITE" ] || { echo "REFUSE: <site> required" >&2; exit 2; }
# --- derive THIS DC's metal-internal CIDR from lib-net (never typed) ---
# shellcheck source=/dev/null
. "$HERE/lib-net.sh"
lib_net_select_dc "$SITE" || { echo "REFUSE: lib_net_select_dc $SITE failed" >&2; exit 2; }
MI_CIDR=""
for c in "${PLANE_CIDRS[@]}"; do
[ "${PLANE_NAME[$c]:-}" = "metal-internal" ] && { MI_CIDR="$c"; break; }
done
[ -n "$MI_CIDR" ] || { echo "REFUSE: no metal-internal plane in lib-net for $SITE" >&2; exit 2; }
MODEL="${JUJU_MODEL:-$SITE}"
# --- render the cloudinit-userdata YAML ---
# Built with printf, one line per statement, the CIDR passed as a %s ARGUMENT (the
# repo's heredoc-whitespace rule). The runcmd is deterministic + idempotent: for each
# of the node's own IPv4 addresses that lies IN this DC's metal-internal CIDR, append
# `<ip> <hostname>.maas <hostname>` to /etc/hosts unless already present. The CIDR
# membership test uses python3 (present on every Ubuntu node image) so a /22 is handled
# exactly, not by fragile string-prefix matching.
render() {
printf '%s\n' '#cloud-config'
printf '%s\n' '# GENERATED by scripts/dc-node-etchosts.sh -- do not hand-edit the deployed value.'
printf '%s\n' '# Gives each node a local /etc/hosts reverse mapping for its metal-internal'
printf '%s\n' '# address so cert-CN-from-get_hostname resolves on the isolated plane (D-052).'
printf '%s\n' 'runcmd:'
printf '%s\n' '- - /bin/bash'
printf '%s\n' ' - -c'
printf '%s\n' " - 'for ip in \$(hostname -I); do if python3 -c \"import ipaddress,sys; sys.exit(0 if ipaddress.ip_address(sys.argv[1]) in ipaddress.ip_network(sys.argv[2]) else 1)\" \"\$ip\" \"${MI_CIDR}\" 2>/dev/null; then grep -qw \"\$ip\" /etc/hosts || echo \"\$ip \$(hostname).maas \$(hostname)\" >> /etc/hosts; fi; done'"
}
case "$MODE" in
render)
render
;;
apply)
command -v juju >/dev/null 2>&1 || { echo "REFUSE: no juju client on this host (run apply on the DC rack, D-138)" >&2; exit 2; }
UD="$(render)"
echo "== applying cloudinit-userdata to model '$MODEL' (metal-internal ${MI_CIDR}) =="
printf '%s\n' "$UD"
juju model-config -m "$MODEL" "cloudinit-userdata=$UD" || { echo "FAIL: juju model-config set failed" >&2; exit 1; }
echo "OK: set. VERIFY at provision time -- this affects machines created AFTER this point only."
;;
check)
command -v juju >/dev/null 2>&1 || { echo "REFUSE: no juju client on this host" >&2; exit 2; }
GOT="$(juju model-config -m "$MODEL" cloudinit-userdata 2>/dev/null)"
fail=0
printf '%s' "$GOT" | grep -q "$MI_CIDR" || { echo "FAIL: model cloudinit-userdata missing this DC's metal-internal CIDR $MI_CIDR"; fail=1; }
printf '%s' "$GOT" | grep -q '/etc/hosts' || { echo "FAIL: model cloudinit-userdata has no /etc/hosts runcmd"; fail=1; }
printf '%s' "$GOT" | grep -q 'ip_network' || { echo "FAIL: model cloudinit-userdata missing the CIDR-membership guard"; fail=1; }
[ "$fail" -eq 0 ] && { echo "OK: model '$MODEL' cloudinit-userdata carries the metal-internal /etc/hosts mapping for $MI_CIDR"; exit 0; } || exit 1
;;
esac