#!/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 argument sidesteps that entirely. def kubectl_json [args: list]: 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 [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 } } }