provisioning-core/nulib/main_provisioning/gateway.nu

202 lines
8.3 KiB
Text
Raw Permalink Normal View History

#!/usr/bin/env nu
# provisioning gateway — reconcile a Cilium Gateway's HTTPS listeners against
# live ground truth (HTTPRoutes + Certificates), so a wiped/drifted Gateway
# (e.g. a taskserv re-apply that clobbers spec.listeners) can be restored with
# one command instead of manual archaeology across every app's HTTPRoute.
#
# Ground truth, not the .ncl declarations: every app's listener_name/hostname
# convention already lives in its own HTTPRoute (parentRefs[].sectionName) and
# Certificate (spec.secretName) — reading those directly is exact, where
# re-deriving names from component .ncl fields would have to guess a naming
# convention that isn't actually uniform across components (verified by hand
# 2026-07-06: e.g. odoo-jesusperez's listener is "https-jesusperez", not
# "https-odoo-jesusperez").
#
# Requires kubectl configured against the target cluster (VPN, or run on a
# control-plane node) — this is live reconciliation, not a local render.
# Args as a list literal, not rest params — nu's parser checks bare rest
# tokens against this command's own flag signature, so a literal "-A" or
# "--all-namespaces" meant for kubectl gets rejected as an unknown flag on
# kubectl_json itself. A list<string> argument sidesteps that entirely.
def kubectl_json [args: list<string>]: nothing -> any {
let r = (^kubectl ...$args -o json | complete)
if $r.exit_code != 0 {
error make --unspanned { msg: $"kubectl ($args | str join ' ') failed: ($r.stderr | str trim)" }
}
$r.stdout | from json
}
# Every HTTPS listener a Gateway should have, derived from HTTPRoutes that
# attach to it (sectionName starting with "https-") plus the Certificate that
# covers each route's hostname.
def expected-listeners [gw_name: string, gw_ns: string]: nothing -> table {
let httproutes = (kubectl_json [get httproute --all-namespaces] | get items)
let certs = (kubectl_json [get certificate --all-namespaces] | get items)
$httproutes
| each {|hr|
let ns = $hr.metadata.namespace
$hr.spec.parentRefs? | default []
| where {|p| (
($p.name? | default "") == $gw_name
and ($p.namespace? | default "kube-system") == $gw_ns
and ($p.sectionName? | default "" | str starts-with "https-")
) }
| each {|p|
let hostname = ($hr.spec.hostnames? | default [] | get -o 0 | default "")
let cert = (
$certs
| where {|c| (
$c.metadata.namespace == $ns
and ($c.spec.dnsNames? | default [] | any {|d| $d == $hostname })
) }
| get -o 0
)
if ($cert | is-empty) {
{
listener: $p.sectionName, hostname: $hostname,
route_ns: $ns, route: $hr.metadata.name,
secret: null, secret_ns: null, cert_ready: false,
status: "no Certificate found for this hostname",
}
} else {
let ready = (
$cert.status?.conditions? | default []
| where {|c| $c.type == "Ready" } | get -o 0.status | default "False"
) == "True"
{
listener: $p.sectionName, hostname: $hostname,
route_ns: $ns, route: $hr.metadata.name,
secret: $cert.spec.secretName, secret_ns: $cert.metadata.namespace,
cert_ready: $ready,
status: (if $ready { "ok" } else { "Certificate not Ready" }),
}
}
}
}
| flatten
}
def live-listeners [gw_name: string, gw_ns: string]: nothing -> table {
kubectl_json [get gateway $gw_name -n $gw_ns]
| get spec.listeners
| where {|l| $l.name != "http" }
| each {|l| {
name: $l.name,
hostname: ($l.hostname? | default ""),
secret: ($l.tls?.certificateRefs?.0.name? | default ""),
secret_ns: ($l.tls?.certificateRefs?.0.namespace? | default ""),
} }
}
# Diff a Gateway's live spec.listeners against what its HTTPRoutes+Certificates
# say should exist. With --apply, adds any missing listener via the same
# additive JSON-patch every component's own install script already uses
# (catalog/lib/gateway-tls.sh:_lib_gateway_patch_https) — never touches or
# removes an existing listener, so re-running this is always safe.
export def "gateway build-listeners" [
--gateway (-g): string # Gateway name (default: libre-wuji)
--gateway-ns: string # Gateway namespace (default: kube-system)
--dry-run
--apply
]: nothing -> table {
let gw_name = ($gateway | default "libre-wuji")
let gw_ns = ($gateway_ns | default "kube-system")
let expected = (expected-listeners $gw_name $gw_ns)
let live = (live-listeners $gw_name $gw_ns)
let missing = ($expected | where {|e| not ($live | any {|l| $l.name == $e.listener }) })
let orphans = ($live | where {|l| not ($expected | any {|e| $e.listener == $l.name }) })
if $apply and not $dry_run {
for m in $missing {
if ($m.secret | is-empty) {
print $" [skip] ($m.listener) — ($m.status), cannot patch without a secret"
continue
}
let patch = (
[{
op: "add", path: "/spec/listeners/-",
value: {
name: $m.listener, port: 443, protocol: "HTTPS", hostname: $m.hostname,
tls: { mode: "Terminate", certificateRefs: [{ group: "", kind: "Secret", name: $m.secret, namespace: $m.secret_ns }] },
allowedRoutes: { namespaces: { from: "All" } },
},
}] | to json
)
let r = (^kubectl patch gateway $gw_name -n $gw_ns --type=json -p $patch | complete)
if $r.exit_code == 0 {
print $" [ok] ($m.listener) -> ($m.hostname) \(($m.secret_ns)/($m.secret)\)"
} else {
print $" [error] ($m.listener): ($r.stderr | str trim)"
}
}
}
if ($orphans | is-not-empty) {
print $"⚠ ($orphans | length) listener\(s\) live with no matching HTTPRoute — not touched, review manually:"
print ($orphans | select name hostname)
}
if ($missing | is-empty) {
print $"✓ Gateway ($gw_ns)/($gw_name) — all ($expected | length) expected HTTPS listener\(s\) present."
} else if not $apply {
print $"($missing | length) missing listener\(s\) — pass --apply to add:"
}
$missing
}
def print_help []: nothing -> nothing {
print "Gateway HTTPS Listener Reconciliation"
print "======================================"
print ""
print "Usage: provisioning gateway <subcommand> [flags]"
print ""
print "Subcommands:"
print " build-listeners (bl) [--gateway NAME] [--gateway-ns NS] [--dry-run] [--apply]"
print " Diff a Gateway's live HTTPS listeners against what its"
print " HTTPRoutes + Certificates say should exist. --apply adds"
print " any missing listener (additive JSON-patch, never removes)."
print ""
print " help Show this help"
print ""
print "Defaults: --gateway libre-wuji --gateway-ns kube-system"
print ""
print "Requires kubectl configured against the target cluster (VPN, or run from a"
print "control-plane node) — this reads and patches live cluster state, it does not"
print "render locally like `dns build-private`."
}
def "main build-listeners" [
--gateway (-g): string
--gateway-ns: string
--dry-run
--apply
]: nothing -> table {
gateway build-listeners --gateway $gateway --gateway-ns $gateway_ns --dry-run=$dry_run --apply=$apply
}
def "main bl" [
--gateway (-g): string
--gateway-ns: string
--dry-run
--apply
]: nothing -> table {
gateway build-listeners --gateway $gateway --gateway-ns $gateway_ns --dry-run=$dry_run --apply=$apply
}
def main [...args: string]: nothing -> nothing {
let first = ($args | get -o 0 | default "")
match $first {
"" | "help" | "h" | "-h" | "--help" => { print_help }
_ => {
print $"Unknown subcommand: ($first)"
print ""
print_help
}
}
}