202 lines
8.3 KiB
Text
202 lines
8.3 KiB
Text
|
|
#!/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
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|