provisioning-core/nulib/cli/components_cluster.nu
Jesús Pérez 6f0de7ae89
core: move cluster introspection helpers to domain/cluster (ADR-026 slice 2b)
Complete the cluster decomposition: the 23 introspection helpers (capacity,
kubectl/ssh summaries, live-check, badges) move from cli/components_cluster.nu to
domain/cluster/introspect.nu. cli/components_cluster.nu now holds ONLY the 5
`main cluster *` verbs (963 -> 452 lines). introspect.nu imports downward only
(tools/nickel, domain/cluster/access, platform/clients/ssh, primitives/logging).

Exported 4 previously-private helpers (fmt-gib, k8s-quantity-bytes,
node-operator-labels, cluster-top-legend) so the cli verbs can call them across
the module boundary; dropped a dead comp-dag-summary import. component.nu repoints
its 11 helper imports to introspect.nu.

Verified: module-load of all touched modules (no cycle, no upward import); ADR-026
topology clean across the whole cluster subsystem; cluster --help + component list
run. Concludes slices 2a+2b: cli/components.nu 3611 -> 2410 (-33%).
2026-07-10 07:24:20 +01:00

452 lines
25 KiB
Text

use domain/workspace/mod.nu *
use platform/user/config.nu [get-active-workspace-details list-workspaces]
use tools/nickel/process.nu [ncl-eval, ncl-eval-soft, default-ncl-paths, comp-ncl-export]
use domain/workspace/state.nu [state-read state-write state-node-get state-node-set state-node-start state-node-finish state-node-delete state-node-reset log-trim]
use platform/clients/orchestrator.nu [orch-url, orch-submit-component, orch-wait-task, orch-health]
use orchestration/taskservs/run.nu [run_taskserv]
use platform/config/accessor.nu [get-taskservs-path]
use domain/utils/templates.nu [on_template_path run_from_template]
use primitives/io/service_check.nu [health-gate]
use primitives/io/logging.nu [is-debug-enabled is-debug-level]
use platform/integrations/context_assembler.nu [build-cabling-credentials-env]
# Cluster-observability slice extracted from components.nu (ADR-026 layer hygiene).
use cli/components.nu [cli-err]
use domain/cluster/introspect.nu [cluster-top-legend comp-load-server-info comp-server-capacity comp-ssh-k8s-data fmt-gib k8s-quantity-bytes node-operator-labels]
use domain/cluster/access.nu [comp-kubectl-access comp-kubectl-ssh]
use platform/clients/ssh.nu [comp-server-ssh-info comp-ssh-run]
use domain/workspace/resolve.nu [comp-ws-path comp-ws-infra comp-state-server comp-resolve-ws-path]
# Live per-node resource usage via `kubectl top`, enriched with the declared
# Hetzner server type and its nominal capacity. provisioning never runs kubectl
# locally — metrics arrive over the bounded remote-kubectl SSH path
# (comp-kubectl-ssh). kubectl's own percentages are relative to node *allocatable*
# (physical RAM minus kernel/kube reservations), which is why the nominal column
# differs from what the % implies. --pods appends the heaviest pods by memory.
export def "main cluster top" [
mode?: string = "" # "pods" appends the heaviest pods by memory
--workspace (-w): string = ""
--infra (-i): string = ""
--limit (-l): int = 15
--out (-o): string = "" # json | yaml | table (default terminal view)
]: nothing -> any {
let pods = ($mode in ["pods", "p"])
let ws_root = (comp-ws-path $workspace)
if ($ws_root | is-empty) { cli-err "no workspace resolved" }
let infra_name = if ($infra | is-not-empty) { $infra } else { comp-ws-infra $ws_root }
if ($infra_name | is-empty) { cli-err $"no infra found under ($ws_root)/infra" }
let nodes_res = (comp-kubectl-ssh $ws_root $infra_name "top nodes --no-headers")
if $nodes_res.exit_code != 0 {
cli-err $"kubectl top nodes failed \(metrics-server reachable?\): ($nodes_res.stderr | str trim)"
}
let servers = (comp-load-server-info $ws_root $infra_name)
# Per-node requested/limited memory vs allocatable — the bin-packing signal
# kubectl top omits (top reports live usage, not scheduler reservations).
# Sourced from pod specs via one SSH JSON fetch; pods in a terminal phase are
# excluded to match kubectl's "Allocated resources". no-req counts containers
# with no memory request (invisible weight that defeats the scheduler).
let access = (comp-kubectl-access $ws_root $infra_name)
let k8s = (comp-ssh-k8s-data $ws_root $infra_name $access.host)
let alloc = ($k8s.nodes | reduce --fold {} {|n, acc|
$acc | insert ($n.metadata.name) (k8s-quantity-bytes ($n.status?.allocatable?.memory? | default "0"))
})
let labelmap = ($k8s.nodes | reduce --fold {} {|n, acc|
$acc | insert ($n.metadata.name) (node-operator-labels ($n.metadata?.labels? | default {}))
})
let agg = ($k8s.pods
| where { (($in.status?.phase? | default "") not-in ["Succeeded", "Failed"]) and (($in.spec?.nodeName? | default "") | is-not-empty) }
| reduce --fold {} {|p, acc|
let node = ($p.spec.nodeName)
let cts = ($p.spec?.containers? | default [])
let req = ($cts | reduce --fold 0 {|c, s| $s + (k8s-quantity-bytes ($c.resources?.requests?.memory? | default "0")) })
let lim = ($cts | reduce --fold 0 {|c, s| $s + (k8s-quantity-bytes ($c.resources?.limits?.memory? | default "0")) })
let nor = ($cts | where { ($in.resources?.requests?.memory? | default "" | is-empty) } | length)
let cur = ($acc | get -o $node | default { req: 0, lim: 0, noreq: 0 })
$acc | upsert $node { req: ($cur.req + $req), lim: ($cur.lim + $lim), noreq: ($cur.noreq + $nor) }
})
let node_rows = ($nodes_res.stdout | str replace --all "\r" "" | lines
| where { ($in | str trim | is-not-empty) }
| each {|ln|
let c = ($ln | str trim | split row --regex '\s+')
let name = ($c | get 0? | default "")
let type = ($servers | get -o $name | default {} | get -o type | default "")
let cap = (comp-server-capacity $type)
let a = ($alloc | get -o $name | default 0)
let g = ($agg | get -o $name | default { req: 0, lim: 0, noreq: 0 })
{
node: $name,
type: (if ($type | is-not-empty) { $type } else { "—" }),
nominal: (if $cap.mem_gb > 0 { $"($cap.cpu)c/($cap.mem_gb)Gi" } else { "—" }),
cpu: ($c | get 1? | default ""),
"cpu%": ($c | get 2? | default ""),
mem: ($c | get 3? | default ""),
"mem%": ($c | get 4? | default ""),
"req%": (if $a > 0 { $"(($g.req * 100 / $a) | math round)%" } else { "—" }),
"lim%": (if $a > 0 { $"(($g.lim * 100 / $a) | math round)%" } else { "—" }),
"no-req": $g.noreq,
labels: ($labelmap | get -o $name | default ""),
}
})
let pod_rows = if $pods {
let pods_res = (comp-kubectl-ssh $ws_root $infra_name "top pods --all-namespaces --no-headers")
if $pods_res.exit_code != 0 {
print --stderr $"kubectl top pods failed: ($pods_res.stderr | str trim)"
[]
} else {
$pods_res.stdout | str replace --all "\r" "" | lines
| where { ($in | str trim | is-not-empty) }
| each {|ln|
let c = ($ln | str trim | split row --regex '\s+')
{
namespace: ($c | get 0? | default ""),
pod: ($c | get 1? | default ""),
cpu: ($c | get 2? | default ""),
mem: ($c | get 3? | default ""),
mem_bytes: (k8s-quantity-bytes ($c | get 3? | default "0")),
}
}
| sort-by mem_bytes --reverse
| first $limit
| reject mem_bytes
}
} else { [] }
let data = if $pods { { nodes: $node_rows, pods: $pod_rows } } else { $node_rows }
match ($out | str downcase) {
"json" => ($data | to json)
"yaml" | "yml" => ($data | to yaml)
"" | "table" => {
if $pods {
print "NODES"
print ($node_rows | table)
cluster-top-legend
print ""
print $"TOP ($limit) PODS BY MEMORY"
$pod_rows
} else {
print ($node_rows | table)
cluster-top-legend
null
}
}
_ => { cli-err $"unknown --out format: ($out) \(use json|yaml|table\)" }
}
}
# Reconcile declared operator node labels onto the live Kubernetes nodes. Source
# of truth: servers.ncl node_labels (record) + node_class (list, projected as
# node-class.<class>=true). Closes the drift where declared labels never reach the
# nodes (no kubectl label runs at join time). Dry-run by default: prints the plan;
# --apply runs `kubectl label node ... --overwrite` over the bounded SSH path.
export def "main cluster label-nodes" [
--workspace (-w): string = ""
--infra (-i): string = ""
--apply (-a)
]: nothing -> any {
let ws_root = (comp-ws-path $workspace)
if ($ws_root | is-empty) { cli-err "no workspace resolved" }
let infra_name = if ($infra | is-not-empty) { $infra } else { comp-ws-infra $ws_root }
if ($infra_name | is-empty) { cli-err $"no infra found under ($ws_root)/infra" }
let settings = (comp-ncl-export $ws_root $"infra/($infra_name)/settings.ncl")
let servers = ($settings | get -o servers | default []
| where { ($in.not_use? | default false) == false })
# Desired operator labels per node: node_labels record + node_class projection.
let desired = ($servers | reduce --fold {} {|s, acc|
let base = ($s.node_labels? | default {})
let cls = ($s.node_class? | default [] | reduce --fold {} {|c, m| $m | insert $"node-class.($c)" "true" })
$acc | insert ($s.hostname) ($base | merge $cls)
})
let access = (comp-kubectl-access $ws_root $infra_name)
let k8s = (comp-ssh-k8s-data $ws_root $infra_name $access.host)
let live = ($k8s.nodes | reduce --fold {} {|n, acc|
$acc | insert ($n.metadata.name) ($n.metadata?.labels? | default {})
})
# Diff via transpose (label keys carry dots/slashes — unsafe for cell-path get).
let plan = ($desired | transpose host want | each {|e|
let have = (($live | get -o $e.host | default {}) | transpose key value)
$e.want | transpose key value | each {|w|
let m = ($have | where key == $w.key)
let cur = if ($m | is-empty) { null } else { ($m | first | get value) }
if $cur != $w.value {
{ node: $e.host, label: $w.key, current: ($cur | default "—"), desired: $w.value }
} else { null }
} | compact
} | flatten)
if ($plan | is-empty) {
print "All declared node labels already applied — nothing to do."
return []
}
if not $apply {
print $"Planned label changes \(($plan | length)\) — dry-run, use --apply to apply:"
return $plan
}
$plan | each {|c|
let r = (comp-kubectl-ssh $ws_root $infra_name $"label node ($c.node) ($c.label)=($c.desired) --overwrite")
{ node: $c.node, label: $c.label, desired: $c.desired, status: (if $r.exit_code == 0 { "applied" } else { ($r.stderr | str trim) }) }
}
}
# Placement simulator: project the per-node impact of (re)deploying a service.
# Reads the candidate's declared resources_effective (memory request) + placement
# from settings, the live node snapshot (allocatable, current pod requests, live
# usage) via the bounded SSH path, then computes projected requests% and
# working-set% per eligible node (eligibility = node-class label ∩ placement.node_class).
# Answers "does the next deploy fit, and where?" — the dry-run the ops loop lacked.
export def "main cluster plan" [
service: string
--workspace (-w): string = ""
--infra (-i): string = ""
--out (-o): string = ""
--req-threshold: int = 80 # % allocatable requests treated as "full"
--use-threshold: int = 90 # % allocatable working-set treated as risky
]: nothing -> any {
let ws_root = (comp-ws-path $workspace)
if ($ws_root | is-empty) { cli-err "no workspace resolved" }
let infra_name = if ($infra | is-not-empty) { $infra } else { comp-ws-infra $ws_root }
if ($infra_name | is-empty) { cli-err $"no infra found under ($ws_root)/infra" }
# Candidate spec from declared NCL (settings.components.<key>).
let settings = (comp-ncl-export $ws_root $"infra/($infra_name)/settings.ncl")
let comps = ($settings | get -o components | default {})
let key = if (($comps | get -o $service) != null) { $service } else { ($service | str replace --all "-" "_") }
let comp = ($comps | get -o $key | default null)
if ($comp == null) { cli-err $"component '($service)' not found in settings.components" }
let re = ($comp | get -o resources_effective | default {})
if ($re | is-empty) { cli-err $"component '($service)' has no resources_effective — declare class/resources first \(step 2c\)" }
let cand_req = (k8s-quantity-bytes ($re | get -o memory | default {} | get -o request | default "0"))
let cand_lim = (k8s-quantity-bytes ($re | get -o memory | default {} | get -o limit | default "0"))
let want_nc = ($comp | get -o placement | default {} | get -o node_class | default [] | each {|c| $c | into string})
let svc_class = ($comp | get -o class | default "")
let access = (comp-kubectl-access $ws_root $infra_name)
let k8s = (comp-ssh-k8s-data $ws_root $infra_name $access.host)
if ($k8s.nodes | is-empty) { cli-err $"could not fetch live nodes \(CP: ($access.host | default 'unresolved'))" }
let top = (comp-kubectl-ssh $ws_root $infra_name "top nodes --no-headers")
let usage = if $top.exit_code == 0 {
$top.stdout | str replace --all "\r" "" | lines | where { ($in | str trim | is-not-empty) }
| reduce --fold {} {|ln, acc|
let c = ($ln | str trim | split row --regex '\s+')
$acc | insert ($c | get 0? | default "") (k8s-quantity-bytes ($c | get 3? | default "0"))
}
} else { {} }
let live_pods = ($k8s.pods | where { (($in.status?.phase? | default "") not-in ["Succeeded", "Failed"]) and (($in.spec?.nodeName? | default "") | is-not-empty) })
let rows = ($k8s.nodes | each {|n|
let name = ($n.metadata.name)
let alloc = (k8s-quantity-bytes ($n.status?.allocatable?.memory? | default "0"))
let labels = ($n.metadata?.labels? | default {} | transpose k v
| where {|r| $r.k | str starts-with "node-class." } | each {|r| $r.k | str replace "node-class." "" })
let eligible = if ($want_nc | is-empty) { true } else { ($want_nc | any {|c| $c in $labels }) }
let cur_req = ($live_pods | where { ($in.spec.nodeName) == $name }
| reduce --fold 0 {|p, s| $s + ($p.spec?.containers? | default [] | reduce --fold 0 {|c, s2| $s2 + (k8s-quantity-bytes ($c.resources?.requests?.memory? | default "0")) }) })
let cur_use = ($usage | get -o $name | default 0)
let proj_req = ($cur_req + $cand_req)
let proj_use = ($cur_use + $cand_req)
let req_pct = (if $alloc > 0 { ($proj_req * 100 / $alloc) | math round } else { 0 })
let use_pct = (if $alloc > 0 { ($proj_use * 100 / $alloc) | math round } else { 0 })
{
node: $name,
eligible: $eligible,
"req now": (if $alloc > 0 { $"(($cur_req * 100 / $alloc) | math round)%" } else { "—" }),
"req→": (if $alloc > 0 { $"($req_pct)%" } else { "—" }),
"ws→": (if $alloc > 0 { $"($use_pct)%" } else { "—" }),
fits: ($eligible and ($req_pct <= $req_threshold) and ($use_pct <= $use_threshold)),
_req_pct: $req_pct,
}
})
let eligible_fit = ($rows | where {|r| $r.eligible and $r.fits } | sort-by _req_pct)
let best = ($eligible_fit | get 0? | get -o node | default "")
let mem_h = ($re | get -o memory | default {})
let summary = {
service: $key, class: $svc_class,
request: ($mem_h | get -o request | default "?"), limit: ($mem_h | get -o limit | default "?"),
node_class: ($want_nc | str join ","),
recommendation: (if ($best | is-not-empty) {
$"schedules on ($best) \(lowest projected requests among eligible nodes that fit\)"
} else {
"NO eligible node fits within thresholds — activate another worker (wrk-1) or resize a node"
}),
}
let out_rows = ($rows | reject _req_pct)
match ($out | str downcase) {
"json" => ({ summary: $summary, nodes: $out_rows } | to json)
"yaml" | "yml" => ({ summary: $summary, nodes: $out_rows } | to yaml)
_ => {
print $"plan: ($key) class=($svc_class) mem req/lim=($summary.request)/($summary.limit) prefers node-class=($summary.node_class)"
print ($out_rows | table)
print $"→ ($summary.recommendation)"
null
}
}
}
# Cluster capacity analysis: per-node working-set broken down by category
# (movable = general app classes, pinned = datastore/storage_engine, infra =
# kube-system/longhorn/cilium/daemonsets), plus an aggregate capacity check by
# node-class with what-if projections. Answers "horizontal vs vertical": movable
# load dominating storage nodes → add a general worker; pinned dominating → resize.
export def "main cluster capacity" [
--workspace (-w): string = ""
--infra (-i): string = ""
--out (-o): string = ""
--add-worker # what-if: add one cax11 general worker (~3.4Gi alloc)
--resize: string = "" # what-if: resize <node> cax11→cax21 (+4Gi alloc)
]: nothing -> any {
let ws_root = (comp-ws-path $workspace)
if ($ws_root | is-empty) { cli-err "no workspace resolved" }
let infra_name = if ($infra | is-not-empty) { $infra } else { comp-ws-infra $ws_root }
if ($infra_name | is-empty) { cli-err $"no infra found under ($ws_root)/infra" }
let settings = (comp-ncl-export $ws_root $"infra/($infra_name)/settings.ncl")
let comps = ($settings | get -o components | default {})
# pod-name-prefix → pinned? (component name with _→- is the usual pod prefix)
let prefixes = ($comps | items {|k v|
{ prefix: ($k | str replace --all "_" "-"), pinned: (($v.class? | default "") in ["datastore", "storage_engine"]) }
} | sort-by {|c| $c.prefix | str length } --reverse)
let infra_ns = ["kube-system", "longhorn-system", "cilium", "cert-manager"]
let access = (comp-kubectl-access $ws_root $infra_name)
let k8s = (comp-ssh-k8s-data $ws_root $infra_name $access.host)
if ($k8s.nodes | is-empty) { cli-err $"could not fetch live nodes \(CP: ($access.host | default 'unresolved'))" }
let top = (comp-kubectl-ssh $ws_root $infra_name "top pods --all-namespaces --no-headers")
let usage = if $top.exit_code == 0 {
$top.stdout | str replace --all "\r" "" | lines | where { ($in | str trim | is-not-empty) }
| reduce --fold {} {|ln, acc|
let c = ($ln | str trim | split row --regex '\s+')
$acc | insert $"($c | get 0? | default '')/($c | get 1? | default '')" (k8s-quantity-bytes ($c | get 3? | default "0"))
}
} else { {} }
let live = ($k8s.pods | where { (($in.status?.phase? | default "") not-in ["Succeeded", "Failed"]) and (($in.spec?.nodeName? | default "") | is-not-empty) })
let per_node = ($k8s.nodes | each {|n|
let name = ($n.metadata.name)
let alloc = (k8s-quantity-bytes ($n.status?.allocatable?.memory? | default "0"))
let labels = ($n.metadata?.labels? | default {} | transpose k v
| where {|r| $r.k | str starts-with "node-class." } | each {|r| $r.k | str replace "node-class." "" })
let pods = ($live | where { ($in.spec.nodeName) == $name })
let cats = ($pods | reduce --fold { movable: 0, pinned: 0, infra: 0 } {|p, acc|
let ns = ($p.metadata.namespace)
let pn = ($p.metadata.name)
let use = ($usage | get -o $"($ns)/($pn)" | default 0)
let cat = if ($ns in $infra_ns) { "infra" } else {
let m = ($prefixes | where {|c| $pn | str starts-with $c.prefix } | get 0? | default null)
if ($m == null) { "movable" } else if $m.pinned { "pinned" } else { "movable" }
}
$acc | upsert $cat (($acc | get $cat) + $use)
})
{ node: $name, class: ($labels | str join ","), alloc: $alloc } | merge $cats
})
# Aggregate by node-class.
let general_alloc = ($per_node | where {|r| "general" in ($r.class | split row ",")} | reduce --fold 0 {|r, s| $s + $r.alloc})
let storage_alloc = ($per_node | where {|r| "storage" in ($r.class | split row ",")} | reduce --fold 0 {|r, s| $s + $r.alloc})
let movable_total = ($per_node | reduce --fold 0 {|r, s| $s + $r.movable})
let pinned_total = ($per_node | reduce --fold 0 {|r, s| $s + $r.pinned})
let cax11_alloc = 3650722201 # ~3.4Gi typical allocatable on a fresh cax11
let resize_node = ($per_node | where node == $resize | get 0? | default null)
let resize_is_general = (if ($resize_node != null) { "general" in ($resize_node.class | split row ",") } else { false })
let resize_is_storage = (if ($resize_node != null) { "storage" in ($resize_node.class | split row ",") } else { false })
let gen_alloc_wi = ($general_alloc
+ (if $add_worker { $cax11_alloc } else { 0 })
+ (if $resize_is_general { 4294967296 } else { 0 }))
let stg_alloc_wi = ($storage_alloc + (if $resize_is_storage { 4294967296 } else { 0 }))
let gen_util_now = (if $general_alloc > 0 { ($movable_total * 100 / $general_alloc | math round) } else { 0 })
let gen_util_wi = (if $gen_alloc_wi > 0 { ($movable_total * 100 / $gen_alloc_wi | math round) } else { 0 })
let scenario = if $add_worker { "+1 general worker (cax11)" } else if ($resize | is-not-empty) { $"resize ($resize) → cax21" } else { "current" }
let mv_s = (fmt-gib $movable_total)
let pn_s = (fmt-gib $pinned_total)
let recommendation = if ($movable_total > $pinned_total) {
$"movable load ($mv_s) dominates pinned ($pn_s) — HORIZONTAL: add a general worker to relieve the storage nodes; vertical resize would keep apps mislocated"
} else {
$"pinned/datastore load ($pn_s) dominates movable ($mv_s) — VERTICAL: resize the pressured storage node rather than adding a general worker"
}
let rows = ($per_node | each {|r|
{
node: $r.node, class: $r.class, alloc: (fmt-gib $r.alloc),
movable: (fmt-gib $r.movable), pinned: (fmt-gib $r.pinned), infra: (fmt-gib $r.infra),
}
})
let summary = {
movable_total: (fmt-gib $movable_total), pinned_total: (fmt-gib $pinned_total),
general_alloc: (fmt-gib $general_alloc), storage_alloc: (fmt-gib $storage_alloc),
general_util_now: $"($gen_util_now)%",
scenario: $scenario,
general_util_scenario: $"($gen_util_wi)%",
recommendation: $recommendation,
}
match ($out | str downcase) {
"json" => ({ nodes: $rows, summary: $summary } | to json)
"yaml" | "yml" => ({ nodes: $rows, summary: $summary } | to yaml)
_ => {
print "PER-NODE working-set by category (movable=general apps · pinned=datastore · infra=k8s/longhorn)"
print ($rows | table)
print ""
print $"movable total: ($summary.movable_total) pinned total: ($summary.pinned_total)"
print $"general node-class alloc: ($summary.general_alloc) storage: ($summary.storage_alloc)"
print $"general utilization \(movable/general-alloc\): now ($summary.general_util_now) → scenario [($scenario)] ($summary.general_util_scenario)"
print $"→ ($recommendation)"
null
}
}
}
# List all Kubernetes services via SSH to the cluster control plane.
# Matches CLI output of `kubectl get svc --all-namespaces`.
export def "main cluster services" [
--workspace (-w): string = ""
--infra (-i): string = ""
--namespace (-n): string = "" # filter to single namespace (default: all)
--out (-o): string = "" # json | yaml | table (default terminal view)
]: nothing -> any {
let ws_root = (comp-ws-path $workspace)
if ($ws_root | is-empty) { cli-err "no workspace resolved" }
let infra_name = if ($infra | is-not-empty) { $infra } else { comp-ws-infra $ws_root }
if ($infra_name | is-empty) { cli-err $"no infra found under ($ws_root)/infra" }
let ns_flag = if ($namespace | is-not-empty) { $"-n ($namespace)" } else { "--all-namespaces" }
let r = (comp-kubectl-ssh $ws_root $infra_name $"get svc ($ns_flag) -o json")
if $r.exit_code != 0 {
cli-err $"kubectl get svc failed: ($r.stderr | str trim)"
}
let items = (try { $r.stdout | from json | get -o items | default [] } catch { [] })
let rows = ($items | each {|svc|
let ns = ($svc.metadata.namespace? | default "")
let name = ($svc.metadata.name? | default "")
let svc_type = ($svc.spec.type? | default "ClusterIP")
let cip = ($svc.spec.clusterIP? | default "")
let ext_ips = ($svc.status.loadBalancer?.ingress? | default []
| each {|ing| $ing.ip? | default ($ing.hostname? | default "") }
| where { $in | is-not-empty } | str join ",")
let ports = ($svc.spec.ports? | default []
| each {|p| $"($p.port?):($p.protocol? | default 'TCP')" } | str join " ")
let has_https = ($svc.spec.ports? | default [] | any {|p|
($p.port? | default 0) == 443 or (($p.name? | default "") | str downcase | str contains "https")
})
{ namespace: $ns, name: $name, type: $svc_type, cluster_ip: $cip, external_ips: $ext_ips, ports: $ports, https: $has_https }
})
match $out {
"json" => { $rows | to json }
"yaml" => { $rows | to yaml }
_ => { $rows }
}
}