provisioning-core/nulib/cli/components.nu

3611 lines
173 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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]
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]
def cli-err [msg: string] {
if (is-debug-enabled) {
error make {msg: $msg}
} else {
error make --unspanned {msg: $msg}
}
}
# Resolve the provisioning root for --import-path resolution.
def comp-prov-root []: nothing -> string {
$env.PROVISIONING? | default "/usr/local/provisioning"
}
# Resolve the deploy directory for a component.
# Checks taskserv/ then cluster/ subdirectories (cluster components use the latter).
# Returns "" if not found.
def comp-find-taskserv-dir [name: string, ...aliases: string]: nothing -> string {
let prov = (comp-prov-root)
let base_candidates = [
$name
($name | str replace --all "_" "-")
($name | str replace --all "-" "_")
]
let extra = ($aliases | each {|a| [$a ($a | str replace --all "_" "-") ($a | str replace --all "-" "_")] } | flatten)
for cand in ($base_candidates | append $extra) {
for subdir in ["taskserv" "cluster"] {
let p = ($prov | path join "catalog" "components" $cand $subdir)
if ($p | path exists) { return $p }
}
}
""
}
# Build a deployable tar.gz bundle for a component using the full run_taskserv pipeline.
# Handles: .j2 template rendering, `prepare` script execution, common.sh injection.
# Returns base64-encoded bundle content ready for the script_compressed payload field.
def comp-build-bundle [
name: string
ws_root: string
infra: string
target: string
op: string
settings_data: record
]: nothing -> string {
let comp_name_field = ($settings_data | get -o components | default {} | get -o $name | default {} | get -o name | default "")
let catalog_ref = ($settings_data | get -o components | default {} | get -o $name | default {} | get -o catalog_ref | default "")
let taskserv_path = (comp-find-taskserv-dir $name $comp_name_field $catalog_ref)
if ($taskserv_path | is-empty) {
cli-err $"No taskserv directory found for component '($name)'"
}
let comp_cfg = ($settings_data | get -o components | default {} | get -o $name | default {})
let settings_file = ($ws_root | path join "infra" $infra "settings.ncl")
let run_settings = {
data: $settings_data,
src: ($settings_file | path basename),
src_path: ($settings_file | path dirname),
infra_path: ($ws_root | path join "infra" $infra),
wk_path: (^mktemp -d | str trim),
providers: ($settings_data | get -o providers | default []),
}
# Merge component settings into taskserv record so .j2 templates see taskserv.* vars
let taskserv_rec = ({
name: $name,
profile: "default",
cmd_task: $op,
install_mode: "library",
depends_on: [],
on_error: "Continue",
} | merge $comp_cfg)
# Build server record matching the shape that handlers.nu passes to run_taskserv:
# full servers.ncl entry merged with ip_addresses.{pub,priv}.
let server_rec = do {
let servers_path = ($ws_root | path join "infra" $infra "servers.ncl")
let raw = (ncl-eval-soft $servers_path (default-ncl-paths $ws_root) null)
let srvr = if ($raw != null) {
$raw | get -o servers | default [] | where {|s| ($s.hostname? | default "") == $target } | get 0?
} else { null }
if ($srvr | is-not-empty) {
let _state_path = ($ws_root | path join "infra" $infra ".servers-state.json")
let _srv_state = if ($_state_path | path exists) {
$_state_path | open | get -o $target | default {}
} else { {} }
let pub_ip = do {
let np = ($srvr | get -o networking.public_ip | default "")
let fp = ($srvr | get -o floating_ip | default "")
if ($np | is-not-empty) {
$np
} else if ($fp | is-not-empty) {
let fip_addr = ($_srv_state | get -o floating_ip_address | default "")
if ($fip_addr | is-not-empty) { $fip_addr } else { $_srv_state | get -o public_ip | default $target }
} else {
let state_ip = ($_srv_state | get -o public_ip | default "")
if ($state_ip | is-not-empty) { $state_ip } else { $target }
}
}
let priv_ip = do {
let np = ($srvr | get -o network_private_ip | default "")
if ($np | is-not-empty) { $np } else { $srvr | get -o networking.private_ip | default "" }
}
$srvr | merge { ip_addresses: { pub: $pub_ip, priv: $priv_ip } }
} else {
{ hostname: $target, ip_addresses: { pub: $target, priv: "" }, main_domain: "", domains_search: "" }
}
}
# run_taskserv uses $env.NOW for timestamped temp dirs — ensure it's set.
if ($env.NOW? | is-empty) {
$env.NOW = (date now | format date "%Y_%m_%d_%H_%M_%S")
}
let wk_tmp = (^mktemp -d | str trim)
let defs = {
settings: $run_settings,
server: $server_rec,
taskserv: $taskserv_rec,
taskserv_install_mode: "library",
taskserv_profile: "default",
pos: { server: "0", taskserv: 0 },
ip: ($server_rec | get -o ip_addresses.pub | default $target),
check: true,
}
let ok = (run_taskserv $defs $taskserv_path ($wk_tmp | path join $name))
rm -rf $wk_tmp
if not $ok {
cli-err $"Bundle build failed for component ($name)"
}
let tar_path = $"/tmp/($name).tar.gz"
if not ($tar_path | path exists) {
# run_taskserv names the tar after taskserv.name (the NCL `name` field, e.g. "kubernetes")
# which may differ from the settings key (e.g. "kubernetes_worker"). Rename if found.
let inner_name = ($taskserv_rec | get -o name | default "")
let alt_tar = $"/tmp/($inner_name).tar.gz"
if ($inner_name | is-not-empty) and ($inner_name != $name) and ($alt_tar | path exists) {
mv $alt_tar $tar_path
} else {
cli-err $"Bundle tar.gz not found after build: ($tar_path)"
}
}
let b64 = (open --raw $tar_path | encode base64)
rm -f $tar_path
$b64
}
# Build a deployable tar.gz bundle for a cluster-mode component.
# Packages catalog/components/<name>/cluster/ + workspace component NCL config.
# The orchestrator SCPs this to the control-plane node and runs install-<name>.sh via SSH.
# Returns {b64: string, work_dir: string}. Caller is responsible for rm -rf work_dir
# unless debug=true, in which case work_dir is printed and preserved for inspection.
def comp-build-cluster-bundle [
name: string
ws_root: string
infra: string
target: string
op: string
settings_data: record
debug: bool = false
skip_preflight: bool = false
]: nothing -> record {
let fs_name = ($name | str replace --all "-" "_")
let prov = (comp-prov-root)
# Multi-tenant components (e.g. odoo_jesusperez) share one extension dir (odoo/cluster/).
# Resolution order: catalog_ref field → comp_cfg.name → settings key.
let comp_cfg_raw = ($settings_data | get -o components | default {} | get -o $fs_name | default {})
let catalog_ref_raw = ($comp_cfg_raw | get -o catalog_ref | default "" | into string)
let ext_name_raw = ($comp_cfg_raw | get -o name | default "" | into string)
let ext_fs_name = if ($catalog_ref_raw | is-not-empty) {
$catalog_ref_raw | str replace --all "-" "_"
} else if ($ext_name_raw | is-not-empty) {
$ext_name_raw | str replace --all "-" "_"
} else {
$fs_name
}
let cluster_dir = ($prov | path join "catalog" "components" $ext_fs_name "cluster")
if not ($cluster_dir | path exists) {
cli-err $"No cluster/ directory found for component '($name)' at ($cluster_dir)"
}
let work_dir = (^mktemp -d | str trim)
let bundle_dir = ($work_dir | path join $name)
^mkdir -p $bundle_dir
# Copy cluster scripts and templates into bundle.
^cp -r $"($cluster_dir)/." $bundle_dir
# Include catalog/lib/ shared scripts so component libs can source shared primitives.
let catalog_lib_src = (($env.PROVISIONING_CATALOG_PATH? | default ($prov | path join "catalog")) | path join "lib")
if ($catalog_lib_src | path exists) {
^cp -rp $catalog_lib_src ($bundle_dir | path join "lib")
}
# Create name aliases so the orchestrator (hyphens) and preflight (underscores) both
# find install-<name>.sh and <name>-lib.sh regardless of which extension the scripts came from.
if $ext_fs_name != $fs_name {
# Multi-tenant / catalog_ref: extension scripts use ext_fs_name as base (catalog dir name).
# Orchestrator runs install-<name>.sh (hyphens); run-init.sh sources <fs_name>-lib.sh (underscores).
# Only two aliases needed — no hyphen-lib or underscore-install copies.
for pair in [
[$"install-($ext_fs_name).sh" $"install-($name).sh"]
[$"($ext_fs_name)-lib.sh" $"($fs_name)-lib.sh"]
] {
let src = ($bundle_dir | path join ($pair | get 0))
let dst = ($bundle_dir | path join ($pair | get 1))
if ($src | path exists) and not ($dst | path exists) {
^cp $src $dst
}
}
} else if $fs_name != $name {
# Standard: fs_name uses underscores, name uses hyphens — create hyphen alias.
for pair in [
[$"install-($fs_name).sh" $"install-($name).sh"]
[$"($fs_name)-lib.sh" $"($name)-lib.sh"]
] {
let src = ($bundle_dir | path join ($pair | get 0))
let dst = ($bundle_dir | path join ($pair | get 1))
if ($src | path exists) and not ($dst | path exists) {
^cp $src $dst
}
}
}
# Write resolved component config as JSON so the install script can read it.
# Inject cluster kubeconfig into comp_cfg so templates access it via {{ name.kubeconfig }}.
# Source: settings.kubernetes.kubeconfig (kubeadm) or settings.k0s.kubeconfig (k0s).
let cluster_kubeconfig = (
$settings_data | get -o kubernetes | default {} | get -o kubeconfig | default (
$settings_data | get -o k0s | default {} | get -o kubeconfig | default ""
)
)
let comp_cfg_raw = ($settings_data | get -o components | default {} | get -o $fs_name | default {})
let comp_cfg = if ($cluster_kubeconfig | is-not-empty) and (($comp_cfg_raw | get -o kubeconfig | default "") | is-empty) {
$comp_cfg_raw | insert kubeconfig $cluster_kubeconfig
} else {
$comp_cfg_raw
}
let settings_json = ($settings_data | to json)
$settings_json | save --force ($bundle_dir | path join "settings.json")
($comp_cfg | to json) | save --force ($bundle_dir | path join "component.json")
# Generate config.env from component NCL settings — install script sources this.
# Maps component record fields → STALWART_* env vars (plus RELAY_* for relay block).
let prefix = ($fs_name | str upcase)
mut config_lines: list<string> = []
# TODO(provider-middleware): resolve lb_ipam_ip via Hetzner provider middleware instead of direct hcloud calls.
# When the provider query layer is wired, replace this with a provider-aware FIP lookup by namespace/role.
# let lb_ipam_ip_resolved = (
# let v = ($comp_cfg | get -o lb_ipam_ip | default "" | into string)
# if ($v | is-not-empty) { $v } else {
# let hcloud_secret = ($ws_root | path join "infra" $infra "secrets" "hcloud.sops.yaml")
# let kage = ($ws_root | path join "infra" $infra ".kage")
# let sops_c = ($ws_root | path join "infra" $infra "sops.yaml")
# let comp_ns = ($comp_cfg | get -o namespace | default $name | into string)
# if ($hcloud_secret | path exists) and ($kage | path exists) {
# let tok_r = do {
# with-env { SOPS_AGE_KEY_FILE: $kage } {
# ^sops --decrypt --config $sops_c --output-type dotenv $hcloud_secret
# }
# } | complete
# if $tok_r.exit_code == 0 {
# let token = ($tok_r.stdout | lines | where {|l| $l | str starts-with "token=" } | first | str replace "token=" "" | str trim)
# let fips_r = do { with-env { HCLOUD_TOKEN: $token } { ^hcloud floating-ip list -o json } } | complete
# if $fips_r.exit_code == 0 {
# let desc_match = ($comp_ns | str downcase)
# $fips_r.stdout | from json | where {|f| ($f | get -o description | default "" | str downcase) | str contains $desc_match } | get 0? | get -o ip | default ""
# } else { "" }
# } else { "" }
# } else { "" }
# }
# )
let lb_ipam_ip_resolved = ($comp_cfg | get -o lb_ipam_ip | default "" | into string)
# Shared SOPS paths — used here for zone-secret injection and below for TLS/credentials.
let kage_path = ($ws_root | path join "infra" $infra ".kage")
let sops_cfg = ($ws_root | path join "infra" $infra "sops.yaml")
# Decrypt zone DNS secret if comp_cfg carries a secret_ref (top-level or first cluster_issuer).
# Injects cf_api_token into comp_cfg so vars.nu receives it via the JSON argument — no env var needed.
let zone_secret_ref_top = ($comp_cfg | get -o secret_ref | default "" | into string)
let zone_secret_ref_issuers = (
$comp_cfg | get -o cluster_issuers | default []
| where {|i| ($i | get -o secret_ref | default "") | is-not-empty }
| get 0? | default {} | get -o secret_ref | default ""
)
let zone_secret_ref = if ($zone_secret_ref_top | is-not-empty) { $zone_secret_ref_top } else { $zone_secret_ref_issuers }
let comp_cfg = if ($zone_secret_ref | is-not-empty) and ($kage_path | path exists) {
let zone_sops = ($ws_root | path join "infra" $infra "secrets" $"($zone_secret_ref).sops.yaml")
if ($zone_sops | path exists) {
let dec = (do {
with-env { SOPS_AGE_KEY_FILE: $kage_path } {
^sops --decrypt --config $sops_cfg --output-type yaml $zone_sops
}
} | complete)
if $dec.exit_code == 0 {
let token = ($dec.stdout | from yaml | get -o token | default "")
if ($token | is-not-empty) { $comp_cfg | upsert cf_api_token $token } else { $comp_cfg }
} else { $comp_cfg }
} else { $comp_cfg }
} else { $comp_cfg }
# Resolve gateway_scope → gateway_ip from cluster networking.gateways when set.
# Allows components to declare 'private/'public instead of hardcoding IPs.
# Path: settings.ncl exports { settings: { networking: { gateways: {...} } }, ... }
let _cluster_gateways = (
$settings_data
| get -o settings | default {}
| get -o networking | default {}
| get -o gateways | default {}
)
let comp_cfg = if not ($comp_cfg | get -o gateway_scope | default "" | is-empty) {
let _scope = ($comp_cfg | get gateway_scope | into string)
let _resolved = ($_cluster_gateways | get -o $_scope | default "")
if ($_resolved | is-not-empty) { $comp_cfg | upsert gateway_ip $_resolved } else { $comp_cfg }
} else { $comp_cfg }
# vars.nu: derived vars merged into the component record before tera rendering.
let vars_nu = ($cluster_dir | path join "vars.nu")
let derived = if ($vars_nu | path exists) {
let r = do { ^nu $vars_nu ($comp_cfg | to json) } | complete
if $r.exit_code != 0 {
error make { msg: $"vars.nu failed for ($name):\n($r.stderr | str trim)" }
}
$r.stdout | str trim | from json
} else { {} }
# vars JSON for tera: component config nested under "taskserv" key → {{ taskserv.name }}.
# vars.nu-derived fields (cf_secret_name, gateway_ip, tls_secret, …) go at the root level.
# Multi-tenant: odoo_jesusperez and odoo_diegodelgado both resolve to the same "taskserv" key.
let tera_ctx = ({taskserv: ($comp_cfg | upsert lb_ipam_ip $lb_ipam_ip_resolved)} | merge $derived)
let vars_json_path = ($work_dir | path join "vars.json")
($tera_ctx | to json) | save --force $vars_json_path
# Render env-<ext_name>.j2 → env-<ext_name> (shell env file sourced by install script).
# Use direct run_from_template (not on_template_path) to avoid recursing into templates/,
# which would cause double-render and duplicate content via save --append in run_from_template.
let env_j2 = ($bundle_dir | path join $"env-($ext_fs_name).j2")
if ($env_j2 | path exists) {
let env_out = ($bundle_dir | path join $"env-($ext_fs_name)")
rm -f $env_out
run_from_template $env_j2 $vars_json_path $env_out --only_make
rm -f $env_j2
}
# Multi-tenant: run-<op>.sh sources env-($fs_name); alias to the rendered env-($ext_fs_name).
if $ext_fs_name != $fs_name {
let env_src = ($bundle_dir | path join $"env-($ext_fs_name)")
let env_dst = ($bundle_dir | path join $"env-($fs_name)")
if ($env_src | path exists) and not ($env_dst | path exists) {
^cp $env_src $env_dst
}
}
# Render templates/*.j2 → manifests/*.yaml (K8s manifests applied by install script).
# Layer 1: extension templates (provisioning/catalog/components/<name>/cluster/templates/)
# Layer 2: workspace overlay (infra/<infra>/components/<name>/templates/) — same name wins.
let tmpl_dir = ($bundle_dir | path join "templates")
let out_dir = ($bundle_dir | path join "manifests")
^mkdir -p $out_dir
if ($tmpl_dir | path exists) {
on_template_path $tmpl_dir $vars_json_path true true
for f in (glob $"($tmpl_dir)/*") { ^mv $f $out_dir }
^rm -rf $tmpl_dir
}
let ws_tmpl_dir = ($ws_root | path join "infra" $infra "components" $fs_name "templates")
if ($ws_tmpl_dir | path exists) {
let ws_tmp = (^mktemp -d | str trim)
^cp -r $"($ws_tmpl_dir)/." $ws_tmp
on_template_path $ws_tmp $vars_json_path true true
for f in (glob $"($ws_tmp)/*") { ^mv -f $f $out_dir }
^rm -rf $ws_tmp
}
# Remove all .j2 source files — tera sources must not reach the remote server.
for j2 in (glob $"($bundle_dir)/**/*.j2") { ^rm -f $j2 }
# Generate manifest_plan.json: load component plan, merge workspace override.
let plan_ncl = ($cluster_dir | path join "manifest_plan.ncl")
let base_plan = if ($plan_ncl | path exists) {
let r = (ncl-eval-soft $plan_ncl (default-ncl-paths $ws_root) null)
if $r != null { $r | get -o manifest_plan | default {} } else { {} }
} else { {} }
let ws_plan = ($comp_cfg | get -o manifest_plan | default {})
let merged_plan = if ($ws_plan | is-empty) {
$base_plan
} else {
$base_plan | merge $ws_plan
}
($merged_plan | to json) | save --force ($bundle_dir | path join "manifest_plan.json")
# Compute op-level hooks from concerns.manifest_hooks (presets) and manifest_plan.hooks (component).
let effective_hooks = (comp-plan-merge-hooks $merged_plan $comp_cfg)
# Generate run-<op>.sh scripts from the plan with injected op-level hooks.
for op in ["init" "update" "delete" "restart"] {
let steps = ($merged_plan | get -o $op | default [])
let op_hooks = ($effective_hooks | get -o $op | default {})
let pre = ($op_hooks | get -o pre | default [])
let post = ($op_hooks | get -o post | default [])
if ($steps | is-not-empty) or ($pre | is-not-empty) or ($post | is-not-empty) {
let script = (comp-plan-op-to-bash $fs_name $op $steps $pre $post)
let run_path = ($bundle_dir | path join $"run-($op).sh")
$script | save --force $run_path
^chmod +x $run_path
}
}
# kage_path and sops_cfg are defined above, before vars.nu invocation.
# Decrypt TLS secret if present and write tls.crt / tls.key into bundle.
# The install script creates the K8s TLS secret directly from these files.
let tls_secret = ($ws_root | path join "infra" $infra "secrets" $"($fs_name)-tls.sops.yaml")
if ($tls_secret | path exists) and ($kage_path | path exists) {
let tls_r = do {
with-env { SOPS_AGE_KEY_FILE: $kage_path } {
^sops --decrypt --config $sops_cfg --output-type yaml $tls_secret
}
} | complete
if $tls_r.exit_code == 0 {
let tls_data = ($tls_r.stdout | from yaml)
let cert = ($tls_data | get -o certificate | default "")
let key = ($tls_data | get -o key | default "")
if ($cert | is-not-empty) { $cert | save --force ($bundle_dir | path join "tls.crt") }
if ($key | is-not-empty) { $key | save --force ($bundle_dir | path join "tls.key") }
}
}
# Decrypt component SOPS secret if present and write _credentials.env into bundle.
# Prefixed with _ — not auto-sourced. Methods that need secrets source it explicitly.
let secret_path = ($ws_root | path join "infra" $infra "secrets" $"($fs_name).sops.yaml")
if ($secret_path | path exists) and ($kage_path | path exists) {
let dec = do {
with-env { SOPS_AGE_KEY_FILE: $kage_path } {
^sops --decrypt --config $sops_cfg --output-type dotenv $secret_path
}
} | complete
if $dec.exit_code == 0 {
# Single-quote every value so bash `source` doesn't expand $-chars (e.g. bcrypt hashes).
let quoted = ($dec.stdout | lines | each {|line|
if ($line | is-empty) or ($line | str starts-with "#") or (not ($line | str contains "=")) {
$line
} else {
let kv = ($line | split row "=" | collect)
$"(($kv | first))='(($kv | skip 1 | str join "=" | str replace --all "'" "'\\''" ))'"
}
} | str join "\n")
$"($quoted)\n" | save --force ($bundle_dir | path join "_credentials.env")
} else {
cli-err $"Failed to decrypt ($secret_path): ($dec.stderr | str trim)"
}
}
# Inject CF tokens into _credentials.env: two sources merged in priority order.
# 1. Zone SOPS files — derived.cf_secret_name (from vars.nu) → secrets/<name>.sops.yaml → token key.
# Produces CF_TOKEN_<ZONE_NAME_UPPER> expected by _method_create-cf-secret.
# 2. Caller environment CF_TOKEN_* — injected by just recipes (lower priority, overridden by #1).
let creds_path = ($bundle_dir | path join "_credentials.env")
mut cf_lines: list<string> = []
# Captured for the provisioning-side DNS reconcile (returned in .dns below):
# the cluster nodes never call Cloudflare, so the token stays on provisioning.
mut dns_token = ""
let cf_secret_name = ($derived | get -o cf_secret_name | default "" | into string)
if ($cf_secret_name | is-not-empty) and ($kage_path | path exists) {
let zone_sops = ($ws_root | path join "infra" $infra "secrets" $"($cf_secret_name).sops.yaml")
if ($zone_sops | path exists) {
let dec = do {
with-env { SOPS_AGE_KEY_FILE: $kage_path } {
^sops --decrypt --config $sops_cfg --output-type dotenv $zone_sops
}
} | complete
if $dec.exit_code == 0 {
let raw_token = ($dec.stdout | lines
| where {|l| ($l | str contains "=") and not ($l | str starts-with "#") }
| first | split row "=" | skip 1 | str join "=" | str trim)
if ($raw_token | is-not-empty) {
let env_key = $"CF_TOKEN_($cf_secret_name | str upcase | str replace --all '-' '_')"
$cf_lines = ($cf_lines | append $"($env_key)='($raw_token)'")
$dns_token = $raw_token
}
}
}
}
let cf_env_tokens = ($env | transpose key val | where { |r| $r.key | str starts-with "CF_TOKEN_" })
for r in $cf_env_tokens {
if not ($cf_lines | any {|l| $l | str starts-with $"($r.key)="}) {
$cf_lines = ($cf_lines | append $"($r.key)='($r.val | str replace --all "'" "'\\''")'")
}
}
if ($cf_lines | is-not-empty) {
let existing = if ($creds_path | path exists) { open --raw $creds_path } else { "" }
$"($existing)\n($cf_lines | str join "\n")\n" | save --force $creds_path
}
# Cabling-based credential injection: if infra/<ws>/integrations/<name>.ncl exists,
# assemble credentials from its bindings and append to _credentials.env.
# Takes precedence over what's already written — last writer wins for duplicate keys.
let cabling_candidates = [
($ws_root | path join "infra" $infra "integrations" $"($fs_name).ncl")
($ws_root | path join "infra" $infra "integrations" $"($name).ncl")
]
let cabling_path = ($cabling_candidates | where {|p| $p | path exists } | get 0? | default "")
if ($cabling_path | is-not-empty) {
let ncl_import = (comp-prov-root)
let cabling_env = (build-cabling-credentials-env $cabling_path
--workspace-root $ws_root
--ncl-import $ncl_import
--kage-path $kage_path)
if ($cabling_env | is-not-empty) {
let creds_path = ($bundle_dir | path join "_credentials.env")
let existing = if ($creds_path | path exists) { open --raw $creds_path } else { "" }
$"($existing)\n# cabling: ($cabling_path | path basename)\n($cabling_env)\n" | save --force $creds_path
}
}
# Preflight var check: collect every _require_env call across all .sh files in the bundle,
# resolve env-<ext_name> + credentials.env into a flat map, and fail early on any empty var.
let pf_env_file = ($bundle_dir | path join $"env-($ext_fs_name)")
let pf_creds_file = ($bundle_dir | path join "_credentials.env")
let pf_env_vals = (
[$pf_env_file $pf_creds_file]
| where {|f| $f | path exists }
| each {|f|
open --raw $f
| lines
| where {|l| not ($l | str starts-with "#") and ($l | str contains "=") }
| each {|l|
let parts = ($l | split row "=" -n 2)
{key: ($parts | get 0? | default "" | str trim), val: ($parts | get 1? | default "" | str trim)}
}
}
| flatten
| where {|r| $r.key | is-not-empty }
| reduce --fold {} {|r acc| $acc | upsert $r.key $r.val }
)
# Only scan run-*.sh (generated op scripts) + *-lib.sh (method implementations).
# Exclude install-*.sh — it has per-operation _require_env guards (backup, restore)
# that are not install-time requirements and would produce false positives.
let pf_sh_candidates = (
[
(try { glob $"($bundle_dir)/run-*.sh" } catch { [] })
(try { glob $"($bundle_dir)/*-lib.sh" } catch { [] })
] | flatten
)
let pf_required = (
$pf_sh_candidates
| each {|sh_path|
open --raw $sh_path
| lines
| where {|l| $l | str contains "_require_env " }
| each {|l|
$l | str trim | split row " " | get 1? | default "" | str trim
| str replace --regex '["\x27]' ""
}
}
| flatten
| where {|v| $v | is-not-empty }
| uniq
)
let pf_missing = ($pf_required | where {|v| ($pf_env_vals | get -o $v | default "" | str trim) | is-empty })
if (not $skip_preflight) and ($pf_missing | is-not-empty) {
for v in $pf_missing { print $"[preflight] ❌ required var not set: ($v)" }
error make {msg: $"Preflight failed — missing env vars: ($pf_missing | str join ', ')"}
}
# DNS admission gate (ADR-051 constraint `preflight-aborts-on-inadmissible`).
# A component that declares a `domain` cannot participate in the op unless its
# DNS state is admissible: the zone token must decrypt AND an A-record target
# (gateway_ip) must resolve. Otherwise the provisioning-side reconcile would
# silently skip (empty token) or publish nothing (empty target_ip) and the
# domain would never resolve while the deploy reported green — the exact
# ontoref failure. Aborting here creates no state (preflight semantics), unlike
# the postdeploy WARN which fires after the mutation already happened.
# Additive: components without a `domain` skip the gate entirely.
let pf_domain = ($comp_cfg | get -o domain | default "" | into string)
if (not $skip_preflight) and ($pf_domain | is-not-empty) {
let pf_gw_ip = (
$derived | get -o gateway_ip
| default ($comp_cfg | get -o gateway_ip | default "")
| into string
)
mut pf_dns_errs: list<string> = []
if ($dns_token | is-empty) {
$pf_dns_errs = ($pf_dns_errs | append $"zone token undecryptable/absent for '($pf_domain)' \(expected secrets/($cf_secret_name).sops.yaml\)")
}
if ($pf_gw_ip | is-empty) {
$pf_dns_errs = ($pf_dns_errs | append $"no A-record target for '($pf_domain)' — gateway_ip empty \(check gateway_scope / hcloud auth\)")
}
if ($pf_dns_errs | is-not-empty) {
for e in $pf_dns_errs { print $"[preflight] ❌ DNS admission: ($e)" }
error make {msg: $"Preflight failed — DNS state inadmissible for ($name): ($pf_dns_errs | str join '; ')"}
}
}
# Verify naming convention: orchestrator runs install-<name>.sh (hyphens); run-init.sh sources <fs_name>-lib.sh (underscores).
for pf_required_script in [$"install-($name).sh" $"($fs_name)-lib.sh"] {
let pf_script_path = ($bundle_dir | path join $pf_required_script)
if not ($pf_script_path | path exists) {
print $"[preflight] ❌ required script missing: ($pf_required_script) component=($name)"
error make {msg: $"Preflight failed — ($pf_required_script) not found in bundle"}
}
}
# Structural contract check — applies to cluster components with a manifest plan.
# Catches authoring errors before packaging: credential filename, method coverage.
let pf_plan_json = ($bundle_dir | path join "manifest_plan.json")
let pf_lib_sh = ($bundle_dir | path join $"($fs_name)-lib.sh")
if ($pf_plan_json | path exists) and ($pf_lib_sh | path exists) {
let pf_install_sh = ($bundle_dir | path join $"install-($name).sh")
if ($pf_install_sh | path exists) {
let pf_bad_cred_source = (
open --raw $pf_install_sh
| lines
| where {|l| not ($l | str starts-with "#") }
| where {|l| $l | str contains "credentials.env" }
| where {|l| not ($l | str contains "_credentials.env") }
)
if ($pf_bad_cred_source | is-not-empty) {
print $"[preflight] ❌ install-($name).sh sources 'credentials.env' — must be '_credentials.env'"
error make {msg: $"Preflight failed — install-($name).sh uses legacy credential filename (see adr-007)"}
}
}
let pf_plan = (open $pf_plan_json)
let pf_builtins = ["apply" "rollout-restart" "delete" "recreate"]
let pf_custom_actions = (
["init" "update" "delete" "restart"]
| each {|op|
$pf_plan | get -o $op | default []
| each {|step|
let main = ($step | get -o action | default "" | str replace --all "'" "")
let posts = ($step | get -o post | default [] | each {|h| $h | get -o action | default "" | str replace --all "'" "" })
let pres = ($step | get -o pre | default [] | each {|h| $h | get -o action | default "" | str replace --all "'" "" })
[$main] ++ $posts ++ $pres
}
| flatten
| where {|action| ($action | is-not-empty) and not ($action in $pf_builtins) }
}
| flatten
| uniq
)
let pf_lib_content = (open --raw $pf_lib_sh)
let pf_missing_methods = (
$pf_custom_actions
| where {|action| not ($pf_lib_content | str contains $"_method_($action)") }
)
if ($pf_missing_methods | is-not-empty) {
for m in $pf_missing_methods {
print $"[preflight] ❌ _method_($m) missing in ($fs_name)-lib.sh"
}
error make {msg: $"Preflight failed — missing method implementations: ($pf_missing_methods | str join ', ')"}
}
# Secret existence + encrypted_regex coverage.
# If lib.sh calls _require_env for any var, the SOPS file must exist and its
# encrypted_regex must cover every required variable name.
let pf_sops_required_vars = (
$pf_lib_content
| lines
| where {|l| $l | str contains "_require_env "}
| where {|l| not ($l | str trim | str starts-with "#")}
| each {|l|
$l | str trim | split row " " | get 1? | default "" | str trim
| str replace --regex '["\x27]' ""
}
| where {|v| $v | is-not-empty}
| uniq
)
if (not $skip_preflight) and ($pf_sops_required_vars | is-not-empty) {
let pf_sops_path = ($ws_root | path join "infra" $infra "secrets" $"($fs_name).sops.yaml")
if not ($pf_sops_path | path exists) {
print $"[preflight] ❌ ($fs_name).sops.yaml not found — required by _require_env: ($pf_sops_required_vars | str join ', ')"
error make {msg: $"Preflight failed — secrets file missing: infra/($infra)/secrets/($fs_name).sops.yaml"}
}
let pf_sops_doc = (open $pf_sops_path)
let pf_encrypted_regex = ($pf_sops_doc | get -o sops.encrypted_regex | default "")
if ($pf_encrypted_regex | str length) > 0 {
# Only check vars that are actually declared in the SOPS file.
# _require_env is also used for config vars sourced from env-<name>,
# which are not secrets and must not be flagged here.
let pf_sops_keys = ($pf_sops_doc | reject sops | columns)
let pf_uncovered_vars = (
$pf_sops_required_vars
| where {|v| $v in $pf_sops_keys}
| where {|v| not ($pf_encrypted_regex | str contains $v)}
)
if ($pf_uncovered_vars | is-not-empty) {
for v in $pf_uncovered_vars {
print $"[preflight] ⚠️ ($v) present in ($fs_name).sops.yaml as plaintext — not covered by encrypted_regex"
}
}
}
}
}
# Pack into tar.gz and base64-encode.
# Files must be at tar root — orchestrator does: tar xzf <name>.tar.gz -C /tmp/<name> && bash install-<name>.sh
# Use gtar (GNU tar) if available to strip macOS xattr headers that confuse Linux tar.
let tar_path = ($work_dir | path join $"($name).tar.gz")
let tar_bin = if (^which gtar | complete).exit_code == 0 { "gtar" } else { "tar" }
let tar_extra = if $tar_bin == "gtar" { ["--no-xattrs"] } else { [] }
do { ^$tar_bin ...$tar_extra -czf $tar_path -C $bundle_dir "." } | complete | ignore
let b64 = (open --raw $tar_path | encode base64)
if $debug {
print $"[preflight] ── Bundle preserved ──────────────────────────────"
print $"[preflight] dir: ($bundle_dir)"
print $"[preflight] manifests: ($bundle_dir)/manifests/"
print $"[preflight] env: ($bundle_dir)/env-($name)"
print $"[preflight] tls: ($bundle_dir)/tls.crt ($bundle_dir)/tls.key"
print $"[preflight] Inspect: ls ($bundle_dir)"
print $"[preflight] cat ($bundle_dir)/out/($name)/configmap.yaml"
print $"[preflight] ─────────────────────────────────────────────────"
print $"[preflight] NOTE: _credentials.env contains secrets — inspect locally only"
} else {
^rm -rf $work_dir
}
# Multi-site components (e.g. rev_proxy): one A record per declared site, all
# pointing at the component FIP. Each site's zone token is decrypted from its
# own SOPS file (dns-<zone>.sops.yaml), mirroring the single-zone block above.
let dns_sites = do {
let sites = ($comp_cfg | get -o sites | default [])
let fip = ($derived | get -o fip_ip | default "")
if ($sites | is-empty) or ($fip | is-empty) { [] } else {
$sites | each {|s|
let domain = ($s | get -o domain | default "")
let zone = ($s | get -o dns_zone | default "")
if ($domain | is-empty) or ($zone | is-empty) { null } else {
let secret = $"dns-($zone | str replace --all '.' '-')"
let zsops = ($ws_root | path join "infra" $infra "secrets" $"($secret).sops.yaml")
let tok = if ($zsops | path exists) and ($kage_path | path exists) {
let dec = (do -i { with-env { SOPS_AGE_KEY_FILE: $kage_path } {
^sops --decrypt --config $sops_cfg --output-type dotenv $zsops
} } | complete)
if $dec.exit_code == 0 {
$dec.stdout | lines
| where {|l| ($l | str contains "=") and not ($l | str starts-with "#") }
| first | default "" | split row "=" | skip 1 | str join "=" | str trim
} else { "" }
} else { "" }
{ token: $tok, domain: $domain, target_ip: $fip, zone: $zone }
}
} | where {|r| $r != null }
}
}
# DNS reconcile inputs — the provisioning-side deploy step upserts the A record(s)
# after a successful install (see the $wait/completed path). Empty domain or
# target_ip → the reconcile skips. Nodes carry no DNS logic.
let dns_reconcile = {
token: $dns_token,
domain: ($comp_cfg | get -o domain | default ""),
target_ip: ($derived | get -o gateway_ip | default ($comp_cfg | get -o gateway_ip | default "")),
zone: ($comp_cfg | get -o dns_zone | default ""),
sites: $dns_sites,
}
{b64: $b64, work_dir: $work_dir, dns: $dns_reconcile}
}
# Generate bash lines for a single hook or method-action step.
def comp-plan-hook-lines [hook: record]: nothing -> list<string> {
let action = ($hook | get -o action | default "noop" | str replace "'" "")
let params = ($hook | get -o params | default {})
let delay = ($hook | get -o delay | default 0)
mut lines: list<string> = []
for kv in ($params | items {|k v| {k: $k, v: $v}}) {
$lines = ($lines | append $"export PLAN_PARAM_($kv.k | str upcase)=\"($kv.v)\"")
}
$lines = ($lines | append $"_method_($action)")
for k in ($params | columns) {
$lines = ($lines | append $"unset PLAN_PARAM_($k | str upcase)")
}
if $delay > 0 { $lines = ($lines | append $"sleep ($delay)") }
$lines
}
# Generate a bash run-<op>.sh script from a list of manifest plan steps.
# Merge op-level hooks from concerns.manifest_hooks (preset) and manifest_plan.hooks (component).
# Concerns hooks run first in pre (infrastructure before app logic).
# Plan hooks run first in post (app cleanup before infrastructure cleanup).
# Known limitation: ws_plan merge is shallow — workspace hooks replace component hooks entirely.
def comp-plan-merge-hooks [plan: record, comp_cfg: record]: nothing -> record {
let ch = ($comp_cfg | get -o concerns | default {} | get -o manifest_hooks | default {})
let ph = ($plan | get -o hooks | default {})
let merge_op = {|op|
let c = ($ch | get -o $op | default {})
let p = ($ph | get -o $op | default {})
{
pre: (($c | get -o pre | default []) | append ($p | get -o pre | default [])),
post: (($p | get -o post | default []) | append ($c | get -o post | default [])),
}
}
{
init: (do $merge_op "init"),
update: (do $merge_op "update"),
delete: (do $merge_op "delete"),
restart: (do $merge_op "restart"),
}
}
def comp-plan-op-to-bash [name: string, op: string, steps: list, hooks_pre: list = [], hooks_post: list = []]: nothing -> string {
mut lines: list<string> = [
"#!/bin/bash",
"set -euo pipefail",
$"# Generated by provisioning bundle builder for ($name):($op) — do not edit",
"SCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"",
"# shellcheck source=/dev/null",
"set -a",
$"[ -f \"$SCRIPT_DIR/env-($name)\" ] && source \"$SCRIPT_DIR/env-($name)\"",
"# _credentials.env is NOT auto-sourced — methods source it explicitly when needed",
"set +a",
$"source \"$SCRIPT_DIR/($name)-lib.sh\"",
"",
]
if ($hooks_pre | is-not-empty) or ($hooks_post | is-not-empty) {
$lines = ($lines | append
"[ -f \"$SCRIPT_DIR/lib/tls-hook-methods.sh\" ] && source \"$SCRIPT_DIR/lib/tls-hook-methods.sh\"")
$lines = ($lines | append "")
}
for step in ($hooks_pre ++ $steps ++ $hooks_post) {
let action = ($step | get -o action | default "apply" | str replace "'" "")
let file = ($step | get -o file | default "")
let skip = ($step | get -o skip_if_exists | default false)
let delay = ($step | get -o delay | default 0)
let params = ($step | get -o params | default {})
let pre = ($step | get -o pre | default [])
let post = ($step | get -o post | default [])
for hook in $pre { $lines = ($lines | append (comp-plan-hook-lines $hook)) }
let label = if ($file | is-not-empty) { $"($action) ($file)" } else { $action }
$lines = ($lines | append $"echo \" [($op)] ($label)\"")
match $action {
"apply" => {
if ($file | is-not-empty) {
if $skip {
$lines = ($lines | append $"_plan_apply_skip \"($file)\"")
} else {
$lines = ($lines | append $"_plan_apply \"($file)\"")
}
}
}
"rollout-restart" => {
if ($file | is-not-empty) {
$lines = ($lines | append $"_plan_rollout_restart \"($file)\"")
}
}
"delete" => {
if ($file | is-not-empty) {
$lines = ($lines | append $"_plan_delete \"($file)\"")
}
}
"recreate" => {
if ($file | is-not-empty) {
$lines = ($lines | append $"_plan_recreate \"($file)\"")
}
}
_ => {
for kv in ($params | items {|k v| {k: $k, v: $v}}) {
$lines = ($lines | append $"export PLAN_PARAM_($kv.k | str upcase)=\"($kv.v)\"")
}
$lines = ($lines | append $"_method_($action)")
for k in ($params | columns) {
$lines = ($lines | append $"unset PLAN_PARAM_($k | str upcase)")
}
}
}
if $delay > 0 { $lines = ($lines | append $"sleep ($delay)") }
for hook in $post { $lines = ($lines | append (comp-plan-hook-lines $hook)) }
$lines = ($lines | append "")
}
$lines | str join "\n"
}
# Export a Nickel file as parsed JSON.
# Always calls nickel directly (no plugin cache) — deploy/ops paths require fresh config.
# Set PROVISIONING_USE_CACHE=true to opt into the plugin cache (read-only queries only).
export def comp-ncl-export [ws_root: string, rel_path: string]: nothing -> record {
let full_path = ($ws_root | path join $rel_path)
let use_cache = ($env.PROVISIONING_USE_CACHE? | default "false") == "true"
if $use_cache {
ncl-eval $full_path (default-ncl-paths $ws_root)
} else {
let import_paths = (default-ncl-paths $ws_root)
let import_args = ($import_paths | each {|p| ["--import-path" $p] } | flatten)
let r = (do { ^nickel export --format json $full_path ...$import_args } | complete)
if $r.exit_code != 0 {
cli-err $"NCL evaluation failed for ($full_path): ($r.stderr | str trim)"
}
$r.stdout | from json
}
}
# Structured compute capacity for a Hetzner server type code: parsed CPU and RAM
# for capacity math (cluster top, the placement simulator) plus a human label.
# Unknown codes yield zero capacity and the code itself as label, so callers can
# degrade gracefully instead of mis-reporting headroom.
export def comp-server-capacity [type_code: string]: nothing -> record {
let tbl = {
cax11: { cpu: 2, mem_gb: 4, arch: "ARM" }, cax21: { cpu: 4, mem_gb: 8, arch: "ARM" },
cax31: { cpu: 8, mem_gb: 16, arch: "ARM" }, cax41: { cpu: 16, mem_gb: 32, arch: "ARM" },
cpx11: { cpu: 2, mem_gb: 2, arch: "AMD" }, cpx21: { cpu: 3, mem_gb: 4, arch: "AMD" },
cpx31: { cpu: 4, mem_gb: 8, arch: "AMD" }, cpx41: { cpu: 8, mem_gb: 16, arch: "AMD" },
cpx51: { cpu: 16, mem_gb: 32, arch: "AMD" },
cx22: { cpu: 2, mem_gb: 4, arch: "Intel" }, cx32: { cpu: 4, mem_gb: 8, arch: "Intel" },
cx42: { cpu: 8, mem_gb: 16, arch: "Intel" }, cx52: { cpu: 16, mem_gb: 32, arch: "Intel" },
}
let spec = ($tbl | get -o $type_code)
if ($spec == null) {
{ type: $type_code, cpu: 0, cpu_millis: 0, mem_gb: 0, mem_bytes: 0, arch: "", label: $type_code }
} else {
{
type: $type_code,
cpu: $spec.cpu,
cpu_millis: ($spec.cpu * 1000),
mem_gb: $spec.mem_gb,
mem_bytes: ($spec.mem_gb * 1024 * 1024 * 1024),
arch: $spec.arch,
label: $"($spec.cpu)CPU ($spec.mem_gb)GB ($spec.arch)",
}
}
}
# Translate Hetzner server type codes to a human-readable CPU/RAM/arch label.
def hetzner-type-label [type_code: string]: nothing -> string {
(comp-server-capacity $type_code).label
}
def status-dot [status: string]: nothing -> string {
match $status {
"running" => "●"
"off" => "○"
"starting" => "◐"
_ => "○"
}
}
# Load server runtime + declared metadata for all servers in a workspace infra.
# Cache mode: .servers-state.json (status/public_ip/location) + settings.ncl (type/private_ip).
# Live mode: hcloud API directly.
# Returns record keyed by hostname.
export def comp-load-server-info [
ws_root: string
infra: string
--live
]: nothing -> record {
let state_path = ($ws_root | path join "infra" $infra ".servers-state.json")
let cache = if ($state_path | path exists) { open $state_path } else { {} }
let declared = do -i {
let raw = (comp-ncl-export $ws_root $"infra/($infra)/settings.ncl")
$raw | get -o servers | default []
| reduce --fold {} {|srv, acc|
$acc | insert $srv.hostname {
type: ($srv.server_type? | default ""),
private_ip: ($srv | get -o networking.private_ip | default ""),
provider: ($srv.provider? | default "hetzner"),
}
}
} | default {}
if $live {
let res = (do { ^hcloud server list -o json } | complete)
if $res.exit_code != 0 { return $declared }
($res.stdout | from json)
| reduce --fold {} {|h, acc|
let d = ($declared | get -o $h.name | default {})
$acc | insert $h.name {
status: $h.status,
type: ($h | get -o server_type.name | default ($d.type? | default "")),
location: ($h | get -o datacenter.location.name | default ""),
public_ip: ($h | get -o public_net.ipv4.ip | default ""),
private_ip: ($d.private_ip? | default ""),
provider: ($d.provider? | default "hetzner"),
}
}
} else {
$cache | items {|hostname entry|
let d = ($declared | get -o $hostname | default {})
{
key: $hostname,
value: {
status: ($entry.status? | default ""),
type: ($d.type? | default ""),
location: ($entry.location? | default ""),
public_ip: ($entry.public_ip? | default ""),
private_ip: ($d.private_ip? | default ""),
provider: ($d.provider? | default "hetzner"),
},
}
} | reduce --fold {} {|it, acc| $acc | insert $it.key $it.value}
}
}
# One-line server info badge for the TASKSERV section header.
export def comp-server-badge [info: record]: nothing -> string {
if ($info | is-empty) { return "" }
let dot = (status-dot ($info.status? | default ""))
let status = ($info.status? | default "")
let type_l = if ($info.type? | default "" | is-not-empty) {
hetzner-type-label $info.type
} else { "" }
let loc = ($info.location? | default "")
let pub = ($info.public_ip? | default "")
let priv = ($info.private_ip? | default "")
let ip_str = if ($pub | is-not-empty) and ($priv | is-not-empty) {
$"($pub) / ($priv)"
} else if ($pub | is-not-empty) { $pub } else { $priv }
let parts = [$"($dot) ($status)" $type_l $loc $ip_str] | where { $in | is-not-empty }
if ($parts | is-empty) { "" } else { $parts | str join " " }
}
def cluster-cache-path [ws_root: string, infra: string]: nothing -> string {
$ws_root | path join "infra" $infra ".cluster-state.json"
}
# Read the declared k0s version from settings.ncl (settings.k0s.version).
export def comp-settings-k0s-version [ws_root: string, infra: string]: nothing -> string {
let raw = (comp-ncl-export $ws_root $"infra/($infra)/settings.ncl")
$raw | get -o settings | default {} | get -o k0s | default {} | get -o version | default ""
}
# Resolve how to reach the cluster's kube-apiserver for an infra.
# provisioning NEVER runs kubectl locally: the API server is reachable only from
# the control-plane node. The SSH host and remote kubectl invocation are declared
# in settings.cluster_access (ssh_host + remote_kubectl). When unset, fall back to
# the first server present in the state file and `k0s kubectl`.
export def comp-kubectl-access [ws_root: string, infra: string]: nothing -> record {
let raw = (comp-ncl-export $ws_root $"infra/($infra)/settings.ncl")
let s = ($raw | get -o settings | default {})
let ca = ($s | get -o cluster_access | default {})
# Operator-delegated workspaces (adr-002) own no cluster — they deploy onto a
# target cluster. With no local cluster_access, resolve the target workspace's
# access so kubectl reads hit the operator cluster's control-plane, not a CP
# this workspace does not have. Guarded: a failed cross-workspace resolve
# falls through to the local default rather than aborting the caller.
let target_ws = ($s | get -o deployment_target | default {}
| get -o target_cluster | default {} | get -o workspace | default "")
if ($ca | is-empty) and ($target_ws | is-not-empty) {
let delegated = (try {
let tgt_root = (comp-ws-path $target_ws)
if ($tgt_root | is-not-empty) and ($tgt_root != $ws_root) and ($tgt_root | path exists) {
comp-kubectl-access $tgt_root (comp-ws-infra $tgt_root)
} else { null }
} catch { null })
if ($delegated != null) { return $delegated }
}
let host = ($ca | get -o ssh_host | default "")
let kc = ($ca | get -o remote_kubectl | default "k0s kubectl")
let resolved_host = if ($host | is-not-empty) { $host } else {
let state_root = if (($ws_root | path join "infra" $infra ".provisioning-state.ncl") | path exists) {
$ws_root | path join "infra" $infra
} else { $ws_root }
comp-state-server $state_root
}
# ws_root/infra identify the workspace that OWNS the resolved host, so callers
# resolve SSH connection details (ip/user/key) from the right servers.ncl even
# when access was delegated from an operator-delegated workspace.
{ host: $resolved_host, kubectl: $kc, ws_root: $ws_root, infra: $infra }
}
# Run a remote kubectl command via SSH using the infra's declared cluster_access.
# Hard-bounded so it can never hang: ConnectTimeout caps the connect phase,
# ServerAlive kills a half-dead session, and a remote `timeout` caps the kubectl
# call itself. Returns the {exit_code, stdout, stderr} record from `complete`.
export def comp-kubectl-ssh [
ws_root: string,
infra: string,
kargs: string, # kubectl arguments, e.g. "get pods -A -o json"
--cmd-timeout: int = 20, # remote wall-clock cap on the kubectl invocation
]: nothing -> record {
let access = (comp-kubectl-access $ws_root $infra)
if ($access.host | is-empty) {
return { exit_code: 1, stdout: "", stderr: "cluster_access.ssh_host unset and no server in state file" }
}
# Resolve SSH details from the workspace that owns the host (the operator
# cluster when access was delegated), not necessarily the calling workspace.
let info = (comp-server-ssh-info $access.ws_root $access.infra $access.host)
let remote = $"export PATH=/usr/local/bin:/usr/bin:/bin:$PATH; timeout ($cmd_timeout) ($access.kubectl) ($kargs)"
comp-ssh-run $info.user $info.key $info.host $remote
}
# Fetch cluster summary counts via the declared remote kubectl (single SSH call).
# Returns the same shape as comp-cluster-summary's result record. node/pod totals
# come from the caller's already-fetched k8s_data; this adds ns/dply/pvc counts.
def comp-kubectl-summary-counts [ws_root: string, infra: string, version: string, k8s_data: record]: nothing -> record {
let nodes = ($k8s_data.nodes? | default [])
let pods = ($k8s_data.pods? | default [])
let nodes_total = ($nodes | length)
let nodes_ready = ($nodes | where {|n|
($n | get -o status | default {} | get -o conditions | default []) | any { ($in.type? == "Ready") and ($in.status? == "True") }
} | length)
let pods_total = ($pods | length)
let pods_running = ($pods | where { ($in | get -o status | default {} | get -o phase | default "") == "Running" } | length)
let cp_ip = ($nodes | get 0? | default {} | get -o status | default {} | get -o addresses | default [] | where { $in.type? == "InternalIP" } | get 0? | get -o address | default "")
let r = (comp-kubectl-ssh $ws_root $infra "get namespaces,deployments,pvc -A --no-headers -o json")
let counts = if $r.exit_code == 0 {
let items = (try { $r.stdout | from json | get -o items | default [] } catch { [] })
{
ns: ($items | where { ($in.kind? | default "") == "Namespace" } | length),
dply: ($items | where { ($in.kind? | default "") == "Deployment" } | length),
pvc: ($items | where { (($in.kind? | default "") == "PersistentVolumeClaim") and (($in.status?.phase? | default "") == "Bound") } | length),
}
} else { { ns: 0, dply: 0, pvc: 0 } }
{
version: $version,
namespaces: $counts.ns,
deployments: $counts.dply,
pvcs_bound: $counts.pvc,
nodes_ready: $nodes_ready,
nodes_total: $nodes_total,
pods_running: $pods_running,
pods_total: $pods_total,
cp_ip: $cp_ip,
last_sync: (date now | format date "%Y-%m-%dT%H:%M:%SZ"),
}
}
# Fetch (--live) or read cached cluster summary: version, ns/dply/pvc counts, node+pod status.
# Live: second lightweight SSH (counts only — separate from comp-ssh-k8s-data heavy JSON call).
# Cache: writes .cluster-state.json on live fetch; reads it on non-live call.
export def comp-cluster-summary [
ws_root: string
infra: string
k8s_data: record # node/pod data already fetched by comp-ssh-k8s-data
version: string # declared k0s/k8s version from settings — caller resolves via comp-settings-k0s-version
--live
]: nothing -> record {
let empty = {
version: "", namespaces: 0, deployments: 0, pvcs_bound: 0, last_sync: "",
nodes_ready: 0, nodes_total: 0, pods_running: 0, pods_total: 0, cp_ip: "",
}
if not $live {
let cache = (cluster-cache-path $ws_root $infra)
let base = if ($cache | path exists) { open $cache } else { $empty }
return ($base | merge { version: (if ($version | is-not-empty) { $version } else { $base.version? | default "" }) })
}
# Live: counts come from the declared remote kubectl (SSH to control-plane).
# provisioning never runs kubectl locally — see comp-kubectl-access.
let result = (comp-kubectl-summary-counts $ws_root $infra $version $k8s_data)
$result | to json | save -f (cluster-cache-path $ws_root $infra)
$result
}
# One-line cluster summary badge for the CLUSTER COMPONENTS header.
export def comp-cluster-badge [summary: record]: nothing -> string {
if ($summary | is-empty) { return "" }
let ver = ($summary.version? | default "")
let ns = ($summary.namespaces? | default 0)
let pods = ($summary.pods_total? | default 0)
if ($ver | is-empty) and $ns == 0 and $pods == 0 { return "" }
let cp_str = if ($summary.cp_ip? | default "" | is-not-empty) { $"CP: ($summary.cp_ip)" } else { "" }
let ver_str = if ($ver | is-not-empty) { $ver } else { "" }
let nodes_str = if ($summary.nodes_total? | default 0) > 0 { $"($summary.nodes_ready)/($summary.nodes_total) nodes" } else { "" }
let pods_str = if ($summary.pods_total? | default 0) > 0 { $"($summary.pods_running)/($summary.pods_total) pods" } else { "" }
let ns_str = if $ns > 0 { $"($ns) ns" } else { "" }
let dply_str = if ($summary.deployments? | default 0) > 0 { $"($summary.deployments) dply" } else { "" }
let pvcs_str = if ($summary.pvcs_bound? | default 0) > 0 { $"($summary.pvcs_bound) pvcs" } else { "" }
[$cp_str $ver_str $nodes_str $pods_str $ns_str $dply_str $pvcs_str] | where { $in | is-not-empty } | str join " "
}
# Parse a Kubernetes resource quantity (e.g. "529Mi", "1Gi", "1024Ki", "2G") to
# bytes for numeric sorting and capacity math. Unparseable input yields 0.
def k8s-quantity-bytes [q: string]: nothing -> int {
let s = ($q | str trim)
if ($s | is-empty) { return 0 }
let m = ($s | parse --regex '(?<num>[0-9.]+)(?<unit>[A-Za-z]*)' | get 0?)
if ($m == null) { return 0 }
let num = ($m.num | into float)
let mult = (match $m.unit {
"Ki" => 1024.0, "Mi" => 1048576.0, "Gi" => 1073741824.0, "Ti" => 1099511627776.0,
"k" | "K" => 1000.0, "M" => 1000000.0, "G" => 1000000000.0, "T" => 1000000000000.0,
_ => 1.0,
})
(($num * $mult) | math round | into int)
}
# Operator-relevant node labels: drop the well-known system/cloud domains so only
# placement-meaningful labels (node_class, can_use_fip.*, custom) remain. Returns
# a "k=v k=v" string (valueless labels shown as bare keys).
def node-operator-labels [labels: record]: nothing -> string {
let system = ["kubernetes.io", "k8s.io", "hetzner.cloud", "topology.", "beta.", "csi."]
$labels | transpose key value
| where {|r| not ($system | any {|p| $r.key | str contains $p }) }
| each {|r|
# Boolean labels (value "true") and valueless labels show as the bare key —
# if it's present it's set. Non-boolean labels keep key=value.
if (($r.value | default "" | is-empty) or ($r.value == "true")) { $r.key } else { $"($r.key)=($r.value)" }
}
| str join " "
}
# 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\)" }
}
}
# Interpretive legend for `cluster top` columns — printed under the node table so
# the numbers can be read without external context. Suppressed in json/yaml output.
def cluster-top-legend []: nothing -> nothing {
print ""
print "How to read it:"
print " nominal physical CPU/RAM of the Hetzner type (cax11=2c/4Gi, cax21=4c/8Gi)."
print " cpu /mem LIVE usage now (kubectl top). mem% is vs ALLOCATABLE (RAM minus"
print " kernel+kube reservations), so it reads higher than vs nominal."
print " req% sum of pod memory REQUESTS vs allocatable = what the scheduler"
print " reserved. Healthy < 80%; it is the real 'fullness' for placement."
print " lim% sum of pod memory LIMITS vs allocatable = overcommit. > 100% means"
print " pods may collectively exceed RAM → OOM-kill risk under pressure."
print " no-req containers with NO memory request — invisible to the scheduler,"
print " cause blind bin-packing and are first to be OOM-killed."
print " labels operator node labels (node_class, can_use_fip.*, custom) that drive"
print " placement; system/cloud domains (kubernetes.io, hetzner.cloud) hidden."
}
# 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
}
}
}
# Format a byte count as a compact Gi/Mi string.
def fmt-gib [b: int]: nothing -> string {
if $b >= 1073741824 { $"(($b / 1073741824 * 10 | math round) / 10)Gi" } else { $"(($b / 1048576) | math round)Mi" }
}
# 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 }
}
}
# Oldest last_sync across .servers-state.json and .cluster-state.json (= most stale data shown).
export def comp-cache-last-sync [ws_root: string, infra: string]: nothing -> string {
let srv_ts = do {
let p = ($ws_root | path join "infra" $infra ".servers-state.json")
if ($p | path exists) {
open $p | values | each { $in.last_sync? | default "" } | where { $in | is-not-empty }
} else { [] }
}
let cl_ts = do {
let p = (cluster-cache-path $ws_root $infra)
if ($p | path exists) {
let v = ((open $p).last_sync? | default "")
if ($v | is-not-empty) { [$v] } else { [] }
} else { [] }
}
let all = ($srv_ts | append $cl_ts) | where { $in | is-not-empty }
if ($all | is-empty) { "" } else { $all | sort | first }
}
# Load DAG formulas for a workspace infra. Returns [] if dag.ncl is absent.
def comp-load-dag [ws_root: string, infra: string]: nothing -> list {
let dag_path = ($ws_root | path join "infra" $infra "dag.ncl")
if not ($dag_path | path exists) { return [] }
let full_path = ($ws_root | path join $"infra/($infra)/dag.ncl")
let dag = (ncl-eval-soft $full_path (default-ncl-paths $ws_root) null)
if ($dag == null) { return [] }
$dag | get -o formulas | default []
}
# Extract name from a DAG node — handles both taskserv.name and component.name shapes.
def comp-node-name [n: record]: nothing -> string {
let ts_name = ($n | get -o taskserv | default null | get -o name | default "")
if ($ts_name | is-not-empty) { return $ts_name }
$n | get -o component | default null | get -o name | default ""
}
# Locate the directory that holds .provisioning-state.ncl for a workspace+infra pair.
# The dag-executor writes state to src_path (= infra dir) when called via comp-build-bundle,
# but some workspaces have state at the workspace root. Probe both.
def comp-state-root [ws_root: string, infra: string]: nothing -> string {
let infra_path = ($ws_root | path join "infra" $infra)
if (($infra_path | path join ".provisioning-state.ncl") | path exists) {
$infra_path
} else {
$ws_root
}
}
# Query DAG execution state for a component across all formulas.
# workspace_path: directory containing .provisioning-state.ncl — use comp-state-root to resolve.
# Normalizes underscore→hyphen: settings use vol_prepare, DAG/state use vol-prepare.
# Returns list<{formula, server, state, started_at, ended_at, blocker}>.
# Entries for template-formula servers that were never initialized are excluded so that
# worker template formulas (server never deployed) do not dilute the aggregate state
# of components that share a name (e.g., os, k0s) with the template.
def comp-dag-state-for [workspace_path: string, formulas: list, comp_name: string]: nothing -> list {
# Canonical form is always kebab — DAG formula taskserv.name convention.
# State is normalized to kebab by component_workflow and state-normalize.
let normalized = ($comp_name | str replace --all "_" "-")
$formulas | each {|f|
$f.nodes | where {|n| (comp-node-name $n) == $normalized } | each {|node|
let ns = (state-node-get $workspace_path $f.server $normalized)
# A node is "initialized" if dag-executor has ever touched it:
# either it has log entries, or actor.identity is non-empty.
# Pure defaults (log=[], identity="") mean the server template was never deployed.
let initialized = (
($ns.log? | default [] | is-not-empty) or
($ns.actor?.identity? | default "" | is-not-empty)
)
{
_init: $initialized,
formula: $f.id,
server: $f.server,
state: ($ns.state? | default "pending"),
started_at: ($ns.started_at? | default ""),
ended_at: ($ns.ended_at? | default ""),
blocker: ($ns.blocker? | default ""),
log: ($ns.log? | default []),
}
}
} | flatten
| where { |e| $e._init }
| each { |e| $e | reject _init }
}
# Lookup component state directly from the state file (no DAG formula required).
# Used as fallback for cluster-mode components not registered in any DAG formula.
# Returns the same list shape as comp-dag-state-for.
def comp-direct-state-for [workspace_path: string, comp_name: string]: nothing -> list {
# Try kebab form first (canonical after state-normalize), fall back to
# the original underscore form — state files written by older orchestrator
# versions use kebab; newer ones may use underscore. Both must resolve.
let kebab = ($comp_name | str replace --all "_" "-")
let snake = ($comp_name | str replace --all "-" "_")
let st = (state-read $workspace_path)
$st.servers
| transpose hostname srv
| each {|row|
let taskservs = ($row.srv.taskservs? | default {})
# The same taskserv may be recorded under both kebab and snake forms
# (orphan duplicate from a node-name convention change). Pick the
# genuinely-active entry — a stale pending orphan must not shadow the
# completed one — by preferring non-pending state, then most-recent activity.
let cands = ([$kebab $comp_name $snake] | uniq
| each {|k| $taskservs | get -o $k }
| where {|v| ($v != null) and ($v | is-not-empty) })
let ts = if ($cands | is-empty) { {} } else if ($cands | length) == 1 {
$cands | first
} else {
($cands | each {|c|
let advanced = (if (($c.state? | default "pending" | into string) != "pending") { "1" } else { "0" })
let tstamp = (if (($c.ended_at? | default "") != "") { ($c.ended_at? | default "") } else { ($c.started_at? | default "") })
{ node: $c, key: $"($advanced)|($tstamp)" }
} | sort-by key | last | get node)
}
if ($ts | is-empty) { [] } else {
let is_init = (
($ts.log? | default [] | is-not-empty) or
($ts.actor?.identity? | default "" | is-not-empty)
)
if $is_init {
[{
formula: "direct",
server: $row.hostname,
state: ($ts.state? | default "pending"),
started_at: ($ts.started_at? | default ""),
ended_at: ($ts.ended_at? | default ""),
blocker: ($ts.blocker? | default ""),
log: ($ts.log? | default []),
}]
} else { [] }
}
} | flatten
}
# Resolve the first server hostname present in the state file for a workspace.
# Used by --live to find which host to SSH into when the DAG server is unknown.
export def comp-state-server [workspace_path: string]: nothing -> string {
let st = (state-read $workspace_path)
$st.servers
| transpose hostname srv
| where {|row| ($row.srv.taskservs? | default {} | is-not-empty) }
| get 0?
| get -o hostname
| default ""
}
# ─── SSH + k0s kubectl live check infrastructure ─────────────────────────────
# Get SSH connection info for a server (user, key path, host) from servers.ncl.
def comp-server-ssh-info [ws_root: string, infra: string, server: string]: nothing -> record {
let servers_path = ($ws_root | path join "infra" $infra "servers.ncl")
if not ($servers_path | path exists) {
return { user: "root", key: "", host: $server }
}
let raw = (ncl-eval-soft $servers_path (default-ncl-paths $ws_root) null)
if ($raw == null) {
return { user: "root", key: "", host: $server }
}
let srv = ($raw | get -o servers | default [] | where {|s| ($s.hostname? | default "") == $server } | get 0?)
let user = if ($srv | is-not-empty) { ($srv | get -o installer_user | default "root") } else { "root" }
let key_raw = if ($srv | is-not-empty) { ($srv | get -o ssh_key_path | default "") } else { "" }
let key = if ($key_raw | is-not-empty) { $key_raw | path expand } else { "" }
let state_path = ($ws_root | path join "infra" $infra ".servers-state.json")
let srv_state = if ($state_path | path exists) {
$state_path | open | get -o $server | default {}
} else { {} }
let host = if ($srv | is-not-empty) {
let np = ($srv | get -o networking.public_ip | default "")
if ($np | is-not-empty) {
$np
} else {
# floating_ip is a Hetzner API assignment name, not an SSH target.
# Always use public_ip from state for SSH — the server's primary reachable address.
let state_ip = ($srv_state | get -o public_ip | default "")
if ($state_ip | is-not-empty) { $state_ip } else { $server }
}
} else { $server }
{ user: $user, key: $key, host: $host }
}
# Run a command on a remote host via SSH (with or without explicit identity file).
# ConnectTimeout bounds the connect phase; ServerAlive (5s × 3) tears down a
# session that goes silent after connect so a stalled remote command can never
# hang the caller indefinitely. Callers that run kubectl wrap it in `timeout`.
def comp-ssh-run [user: string, key: string, host: string, cmd: string]: nothing -> record {
let opts = [
-o ConnectTimeout=10
-o ServerAliveInterval=5
-o ServerAliveCountMax=3
-o BatchMode=yes
]
if ($key | is-not-empty) {
do { ^ssh -i $key ...$opts $"($user)@($host)" $cmd } | complete
} else {
do { ^ssh ...$opts $"($user)@($host)" $cmd } | complete
}
}
# Single SSH session to CP: fetch pods + nodes as JSON in one connection.
# Returns {pods: list<record>, nodes: list<record>} — parsed from kubectl -o json output.
# Pods have metadata.namespace/name and status.phase/containerStatuses fields.
# Nodes have metadata.name and status.conditions fields.
export def comp-ssh-k8s-data [
ws_root: string,
infra: string,
server: string,
--systemd-services: list<string> = [], # service names to check via systemctl is-active
]: nothing -> record {
let empty = { pods: [], nodes: [], systemd: {} }
if ($server | is-empty) {
print --stderr "[live] no server resolved — cannot fetch k8s data"
return $empty
}
let info = (comp-server-ssh-info $ws_root $infra $server)
if (is-debug-level 2) {
let key_part = if ($info.key | is-not-empty) { $" -i ($info.key)" } else { "" }
print --stderr $"[live] ssh($key_part) ($info.user)@($info.host) kubectl get pods+nodes -o json"
}
# PATH: SSH non-interactive sessions may not have /usr/local/bin (where k0s and kubeadm kubectl live).
# KUBECONFIG: kubeadm clusters set this in ~/.bashrc (not sourced in BatchMode SSH).
# Probe /root/.kube/config then /etc/kubernetes/admin.conf; k0s kubectl ignores KUBECONFIG entirely.
let systemd_snippet = if ($systemd_services | is-not-empty) {
let checks = ($systemd_services | each {|svc| $"printf '($svc):'; systemctl is-active ($svc) 2>/dev/null || true"} | str join "; ")
$"echo '===SYSTEMD==='; ($checks)"
} else { "" }
let cmd_base = "export PATH=/usr/local/bin:/usr/bin:/bin:$PATH; for _kc in /root/.kube/config /etc/kubernetes/admin.conf; do [ -f \"$_kc\" ] && { export KUBECONFIG=\"$_kc\"; break; }; done; kc() { if command -v kubectl >/dev/null 2>&1; then kubectl \"$@\"; else k0s kubectl \"$@\"; fi; }; echo '===PODS==='; kc get pods --all-namespaces -o json 2>/dev/null || echo '{\"items\":[]}'; echo '===NODES==='; kc get nodes -o json 2>/dev/null || echo '{\"items\":[]}'"
let cmd = if ($systemd_snippet | is-not-empty) { $"($cmd_base); ($systemd_snippet)" } else { $cmd_base }
let result = (comp-ssh-run $info.user $info.key $info.host $cmd)
if (is-debug-level 2) {
print --stderr $"[live] SSH exit=($result.exit_code) stdout_len=($result.stdout | str length)"
}
if $result.exit_code != 0 { return $empty }
let all_lines = ($result.stdout | str replace --all "\r" "" | lines)
let pods_idx = ($all_lines | enumerate | where { ($in.item | str trim) == "===PODS===" } | get 0? | get -o index | default (-1))
let nodes_idx = ($all_lines | enumerate | where { ($in.item | str trim) == "===NODES===" } | get 0? | get -o index | default (-1))
let systemd_idx = ($all_lines | enumerate | where { ($in.item | str trim) == "===SYSTEMD===" } | get 0? | get -o index | default (-1))
if (is-debug-level 3) {
print --stderr $"[live] pods_idx=($pods_idx) nodes_idx=($nodes_idx) systemd_idx=($systemd_idx) total_lines=($all_lines | length)"
}
let pods = if $pods_idx >= 0 {
let from = ($pods_idx + 1)
let raw = if $nodes_idx > $pods_idx { $all_lines | skip $from | first ($nodes_idx - $from) } else { $all_lines | skip $from }
try { $raw | str join "\n" | from json | get -o items | default [] } catch {|e|
if (is-debug-level 1) {
print --stderr $"[live] pods JSON parse error: ($e.msg | str substring 0..120)"
print --stderr $"[live] pods first line: ($raw | get 0? | default '')"
}
[]
}
} else { [] }
let nodes = if $nodes_idx >= 0 {
let from = ($nodes_idx + 1)
let raw = if $systemd_idx > $nodes_idx { $all_lines | skip $from | first ($systemd_idx - $from) } else { $all_lines | skip $from }
try { $raw | str join "\n" | from json | get -o items | default [] } catch {|e|
if (is-debug-level 1) {
print --stderr $"[live] nodes JSON parse error: ($e.msg | str substring 0..120)"
}
[]
}
} else { [] }
let systemd = if $systemd_idx >= 0 {
$all_lines | skip ($systemd_idx + 1)
| each {|ln|
let parts = ($ln | str trim | split column ":" name status)
if ($parts | length) >= 1 and ($parts | get 0 | get name | is-not-empty) {
{ ($parts | get 0 | get name): ($parts | get 0 | get status | str trim) }
} else { {} }
}
| reduce -f {} {|it, acc| $acc | merge $it }
} else { {} }
if (is-debug-level 2) {
print --stderr $"[live] parsed pods=($pods | length) nodes=($nodes | length) systemd_keys=($systemd | columns | length)"
}
{ pods: $pods, nodes: $nodes, systemd: $systemd }
}
# Public API: return pod records for a server (used by describe-pod and external callers).
export def comp-ssh-pods [ws_root: string, infra: string, server: string]: nothing -> list {
(comp-ssh-k8s-data $ws_root $infra $server).pods
}
# Run kubectl describe pod -n <namespace> <pod> via SSH. Returns describe output as string.
export def comp-ssh-describe-pod [ws_root: string, infra: string, server: string, namespace: string, pod: string]: nothing -> string {
let info = (comp-server-ssh-info $ws_root $infra $server)
let cmd = $"if command -v kubectl >/dev/null 2>&1; then kubectl describe pod -n ($namespace) ($pod); else k0s kubectl describe pod -n ($namespace) ($pod); fi"
let result = (comp-ssh-run $info.user $info.key $info.host $cmd)
if $result.exit_code != 0 { $result.stderr | str trim } else { $result.stdout | str trim }
}
# Resolve workspace root path (exported for use by component.nu dispatcher).
export def comp-ws-path [workspace: string]: nothing -> string {
comp-resolve-ws-path $workspace
}
# Auto-detect infra name from workspace root (exported for use by component.nu dispatcher).
export def comp-ws-infra [ws_root: string]: nothing -> string {
ls ($ws_root | path join "infra")
| where type == dir
| get name
| each {|p| $p | path basename }
| where {|n| ($ws_root | path join "infra" $n "settings.ncl" | path exists) }
| get 0?
| default ""
}
# Summarise pod records (from kubectl -o json) into {live, detail}.
def comp-kubectl-summarise [pods: list]: nothing -> record {
if ($pods | is-empty) { return { live: "deployed", detail: "no pods" } }
# Exclude Succeeded pods (completed jobs/migrations) — they never return to Running
# and would distort the ratio. Failed phase and error-waiting pods are kept and
# classified as degraded.
let active = ($pods | where { ($in | get -o status | default {} | get -o phase | default "") != "Succeeded" })
let total = ($active | length)
let running = ($active | where { ($in | get -o status | default {} | get -o phase | default "") == "Running" } | length)
let pending = ($active | where { ($in | get -o status | default {} | get -o phase | default "") == "Pending" } | length)
# Any pod in Failed phase OR any container waiting with an error reason → degraded.
let error_reasons = ["CrashLoopBackOff", "ImagePullBackOff", "ErrImagePull",
"Error", "OOMKilled", "CreateContainerError",
"CreateContainerConfigError", "RunContainerError",
"InvalidImageName", "PostStartHookError"]
let failing = ($active | where { |p|
let phase = ($p | get -o status | default {} | get -o phase | default "")
if $phase == "Failed" { true } else {
let cs = ($p | get -o status | default {} | get -o containerStatuses | default [])
let ics = ($p | get -o status | default {} | get -o initContainerStatuses | default [])
($cs | append $ics) | any { |c|
($c | get -o state | default {} | get -o waiting | default {} | get -o reason | default "") in $error_reasons
}
}
} | length)
let live = if $failing > 0 { "degraded"
} else if $pending > 0 { "starting"
} else if $total == 0 { "deployed"
} else if $running == $total { "healthy"
} else { "partial" }
let detail = if $failing > 0 {
$"($running)/($total) pods running ($failing) failing"
} else {
$"($running)/($total) pods running"
}
{ live: $live, detail: $detail }
}
# Filter pod records by namespace (exact match) and name substring.
def comp-filter-pods [namespace: string, search: string, pods: list]: nothing -> list {
let ns_filtered = if ($namespace | is-not-empty) {
$pods | where { ($in | get -o metadata | default {} | get -o namespace | default "") == $namespace }
} else { $pods }
$ns_filtered | where { ($in | get -o metadata | default {} | get -o name | default "") | str contains $search }
}
# Summarise node records (from kubectl -o json) into {live, detail}.
def comp-kubectl-nodes-summarise [nodes: list]: nothing -> record {
if ($nodes | is-empty) { return { live: "—", detail: "no node data" } }
let total = ($nodes | length)
let ready = ($nodes | where { |n|
$n | get -o status | default {} | get -o conditions | default []
| any { ($in | get -o type | default "") == "Ready" and ($in | get -o status | default "") == "True" }
} | length)
let live = if $ready == $total { "healthy"
} else if $ready == 0 { "degraded"
} else { "partial" }
{ live: $live, detail: $"($ready)/($total) nodes Ready" }
}
# Live check for a component using pre-fetched k8s_data {pods, nodes}.
# Resolves strategy from comp.live_check; falls back to namespace/pod_selector on the component.
# 'systemd strategy always returns "—" in live (fast) mode — requires prvng c health.
# Returns {live: string, detail: string}.
export def comp-live-check [name: string, comp: record, k8s_data: record]: nothing -> record {
let lc = ($comp | get -o live_check | default {})
let strategy = ($lc | get -o strategy | default "none" | into string)
match $strategy {
"k8s_pods" => {
if ($k8s_data.pods | is-empty) { return { live: "—", detail: "no pod data" } }
let ns = do {
let explicit = ($lc | get -o namespace | default "")
if ($explicit | is-not-empty) { $explicit } else { $comp | get -o namespace | default "" }
}
let sel = do {
let explicit = ($lc | get -o selector | default "")
if ($explicit | is-not-empty) { $explicit } else {
let ps = ($comp | get -o pod_selector | default "")
if ($ps | is-not-empty) { $ps } else { $name | str replace --all "_" "-" }
}
}
let matching = (comp-filter-pods $ns $sel $k8s_data.pods)
if ($matching | is-empty) { return { live: "—", detail: $"no pods: ($sel)" } }
comp-kubectl-summarise $matching
}
"k8s_nodes" => {
if ($k8s_data.nodes | is-empty) { return { live: "—", detail: "no node data" } }
let sel = ($lc | get -o selector | default "")
let filtered = if ($sel | is-not-empty) {
$k8s_data.nodes | where { ($in | get -o metadata | default {} | get -o name | default "") | str contains $sel }
} else {
$k8s_data.nodes
}
comp-kubectl-nodes-summarise $filtered
}
"k8s_api" => {
# apiserver healthy if kubectl returned any node data
if ($k8s_data.nodes | is-not-empty) or ($k8s_data.pods | is-not-empty) {
{ live: "healthy", detail: "apiserver ok" }
} else {
{ live: "—", detail: "apiserver unreachable" }
}
}
"systemd" => {
let svc = ($lc | get -o service | default "")
if ($svc | is-empty) { return { live: "—", detail: "health: systemd" } }
let units = ($k8s_data | get -o systemd | default {})
if ($units | is-empty) { return { live: "—", detail: "health: systemd" } }
let active = ($units | get -o $svc | default "")
match $active {
"active" => { live: "healthy", detail: $"systemd: ($svc) active" },
"inactive" => { live: "degraded", detail: $"systemd: ($svc) inactive" },
"failed" => { live: "degraded", detail: $"systemd: ($svc) failed" },
_ => { live: "—", detail: $"systemd: ($svc) ($active)" },
}
}
"on_demand" => { live: "—", detail: "on-demand" }
_ => { live: "—", detail: "no live check" }
}
}
# Extract pod summary records for a component from pre-fetched pod JSON records.
# Returns list of {namespace, pod, ready, status}.
export def comp-pods-for [namespace: string, name: string, pods: list, pod_selector: string = ""]: nothing -> list {
let search = if ($pod_selector | is-not-empty) { $pod_selector } else { $name | str replace --all "_" "-" }
(comp-filter-pods $namespace $search $pods)
| each {|p|
let meta = ($p | get -o metadata | default {})
let status = ($p | get -o status | default {})
let cs = ($status | get -o containerStatuses | default [])
let ready = ($cs | where { $in.ready? | default false } | length)
{
namespace: ($meta | get -o namespace | default ""),
pod: ($meta | get -o name | default ""),
ready: $"($ready)/($cs | length)",
status: ($status | get -o phase | default ""),
}
}
}
# Summarise a list of dag state entries into a single string (for list column).
def comp-dag-summary [dag: list]: nothing -> string {
if ($dag | is-empty) { return "—" }
let states = ($dag | each {|e| $e.state })
if ($states | any { $in == "running" }) { return "running" }
if ($states | any { $in == "failed" }) { return "failed" }
if ($states | any { $in == "blocked" }) { return "blocked" }
if ($states | any { $in == "unknown" }) { return "unknown" }
if ($states | all { $in == "completed" }) { return "completed" }
if ($states | any { $in == "completed" }) { return "partial" }
"pending"
}
# Resolve workspace name: explicit --workspace flag or active workspace.
# A path counts as a workspace ONLY if it has real workspace content — not just
# any directory that happens to be called "infra/". Otherwise $HOME/infra/
# (or similar incidental dirs) hijack the walk-up and shadow the active workspace.
def is-workspace-root [path: string]: nothing -> bool {
if (($path | path join ".ontology") | path exists) { return true }
if (($path | path join "config" "provisioning.ncl") | path exists) { return true }
let infra_dir = ($path | path join "infra")
if not ($infra_dir | path exists) { return false }
# Require at least one infra/*/settings.ncl to treat this as a workspace root.
(ls $infra_dir | where type == dir | get name
| any { |p| ($p | path join "settings.ncl" | path exists) })
}
def find-workspace-root-up [path: string]: nothing -> string {
if ($path | is-empty) or $path == "/" { return "" }
if (is-workspace-root $path) { return $path }
let parent = ($path | path dirname)
if $parent == $path { return "" }
find-workspace-root-up $parent
}
# Walk up from $path looking for a sibling `workspaces/<name>/` that qualifies as
# a workspace root. Returns the full path to the workspace, or "" if not found.
def find-named-workspace-up [path: string, name: string]: nothing -> string {
if ($path | is-empty) or $path == "/" { return "" }
let candidate = ($path | path join "workspaces" $name)
if ($candidate | path exists) and (is-workspace-root $candidate) {
return $candidate
}
let parent = ($path | path dirname)
if $parent == $path { return "" }
find-named-workspace-up $parent $name
}
# Derive the project root from $env.PROVISIONING (typically
# `<project-root>/provisioning`). Returns "" if PROVISIONING is unset.
def project-root-from-prov []: nothing -> string {
let prov = ($env.PROVISIONING? | default "")
if ($prov | is-empty) { return "" }
$prov | path dirname
}
# Returns the workspace ROOT PATH (not name) — works for both registered and unregistered workspaces.
def comp-resolve-ws-path [workspace: string]: nothing -> string {
# 1. Explicit --workspace → resolve to a path.
if ($workspace | is-not-empty) {
# 1a. Absolute or CWD-relative path that is itself a workspace root.
let as_path = ($workspace | path expand)
if ($as_path | path exists) and (is-workspace-root $as_path) {
return $as_path
}
# 1b. Registered in the user config.
let registered = (
list-workspaces
| where { |ws| $ws.name == $workspace }
| get -o 0
)
if ($registered | is-not-empty) {
return $registered.path
}
# 1c. Walk up from CWD looking for a sibling `workspaces/<name>/`.
let from_cwd = (find-named-workspace-up $env.PWD $workspace)
if ($from_cwd | is-not-empty) { return $from_cwd }
# 1d. Derive from $env.PROVISIONING — the canonical project-root anchor.
let project_root = (project-root-from-prov)
if ($project_root | is-not-empty) {
let from_prov = ($project_root | path join "workspaces" $workspace)
if ($from_prov | path exists) and (is-workspace-root $from_prov) {
return $from_prov
}
}
cli-err $"Workspace '($workspace)' not found in registry, CWD ancestors, or under ($project_root)/workspaces/"
}
# 2. Detect from CWD: registered workspace whose path is prefix of CWD
let pwd = $env.PWD
let from_registry = (
list-workspaces
| where { |ws| ($ws.path | is-not-empty) and ($pwd | str starts-with $ws.path) }
| sort-by { |ws| ($ws.path | str length) }
| last # longest match wins
)
if not ($from_registry | is-empty) {
return $from_registry.path
}
# 3. Unregistered: walk up CWD until workspace markers found
let ws_root = (find-workspace-root-up $pwd)
if ($ws_root | is-not-empty) {
return $ws_root
}
# 4. Fall back to active workspace
let details = (get-active-workspace-details)
if ($details == null) or ($details.path? | default "" | is-empty) {
cli-err "No active workspace — pass --workspace or activate one first"
}
$details.path
}
# Validate cluster capabilities against real infrastructure state.
#
# Exports infra/{infra}/capabilities.ncl from the workspace and compares declared
# capabilities (storage_classes, ingress_class) against live kubectl output.
# Returns a table of check / expected / actual / status rows.
#
# Usage:
# provisioning validate capabilities --workspace libre-daoshi --infra wuji
export def "main validate capabilities" [
--workspace (-w): string = "" # Workspace name or path (default: auto-detect)
--infra (-i): string = "" # Infra name (default: auto-detect)
]: nothing -> table<check: string, expected: string, actual: string, status: string> {
let ws_root = (comp-resolve-ws-path $workspace)
if ($ws_root | is-empty) {
cli-err "Could not resolve workspace root"
}
let infra = if ($infra | is-not-empty) { $infra } else {
ls ($ws_root | path join "infra") | where type == dir | get name
| each { |p| $p | path basename }
| where { |n| ($ws_root | path join "infra" $n "settings.ncl" | path exists) }
| get 0? | default ""
}
let caps_path = ($ws_root | path join "infra" $infra "capabilities.ncl")
if not ($caps_path | path exists) {
cli-err $"capabilities.ncl not found at ($caps_path)"
}
let caps = (comp-ncl-export $ws_root ($"infra/($infra)/capabilities.ncl"))
mut rows: list<record<check: string, expected: string, actual: string, status: string>> = []
# Check storage classes
let declared_sc = ($caps | get -o provides | default {} | get -o storage_classes | default [] | each { $in | into string })
if ($declared_sc | is-not-empty) {
let sc_result = (comp-kubectl-ssh $ws_root $infra "get sc --no-headers -o custom-columns=NAME:.metadata.name")
let actual_sc = if $sc_result.exit_code == 0 {
$sc_result.stdout | lines | where { $in | is-not-empty }
} else {
[]
}
for sc in $declared_sc {
let found = ($actual_sc | any { $in == $sc })
$rows = ($rows | append {
check: "storage_class",
expected: $sc,
actual: (if $found { $sc } else { "<not found>" }),
status: (if $found { "ok" } else { "MISSING" }),
})
}
}
# Check ingress class
let declared_ic = ($caps | get -o provides | default {} | get -o ingress_class | default "")
if ($declared_ic | is-not-empty) {
let ic_result = (comp-kubectl-ssh $ws_root $infra "get ingressclass --no-headers -o custom-columns=NAME:.metadata.name")
let actual_ic = if $ic_result.exit_code == 0 {
$ic_result.stdout | lines | where { $in | is-not-empty }
} else {
[]
}
let found = ($actual_ic | any { $in == $declared_ic })
$rows = ($rows | append {
check: "ingress_class",
expected: $declared_ic,
actual: (if $found { $declared_ic } else { "<not found>" }),
status: (if $found { "ok" } else { "MISSING" }),
})
}
$rows
}
# Validate component configuration against workspace capabilities and server inventory.
#
# Exports infra/{infra}/settings.ncl and checks each component:
# - taskserv mode: verifies the target server exists in the servers map.
# - cluster mode: verifies the storage_class (if declared) is in capabilities.storage_classes.
# Returns a table of component / check / status / detail rows.
#
# Usage:
# provisioning validate components --workspace libre-daoshi --infra wuji
export def "main validate components" [
--workspace (-w): string = "" # Workspace name or path (default: auto-detect)
--infra (-i): string = "" # Infra name (default: auto-detect)
]: nothing -> table<component: string, check: string, status: string, detail: string> {
let ws_root = (comp-resolve-ws-path $workspace)
if ($ws_root | is-empty) {
cli-err "Could not resolve workspace root"
}
let infra = if ($infra | is-not-empty) { $infra } else {
ls ($ws_root | path join "infra") | where type == dir | get name
| each { |p| $p | path basename }
| where { |n| ($ws_root | path join "infra" $n "settings.ncl" | path exists) }
| get 0? | default ""
}
let settings = (comp-ncl-export $ws_root ($"infra/($infra)/settings.ncl"))
let components = ($settings | get -o components | default {})
# Load capabilities for storage_class cross-check (best-effort: skip if absent).
let caps_path = ($ws_root | path join "infra" $infra "capabilities.ncl")
let caps_sc: list<string> = if ($caps_path | path exists) {
let c = (comp-ncl-export $ws_root ($"infra/($infra)/capabilities.ncl"))
$c | get -o provides | default {} | get -o storage_classes | default [] | each { $in | into string }
} else {
[]
}
# Load servers for taskserv target validation (best-effort).
let servers_path = ($ws_root | path join "infra" $infra "servers.ncl")
let server_names: list<string> = if ($servers_path | path exists) {
ncl-eval-soft $servers_path (default-ncl-paths $ws_root) {} | get -o servers | default {} | columns
} else {
[]
}
mut rows: list<record<component: string, check: string, status: string, detail: string>> = []
let comp_names = ($components | columns)
for comp_name in $comp_names {
let comp = ($components | get $comp_name)
let mode = ($comp | get -o mode | default "cluster")
if $mode == "taskserv" {
let target = ($comp | get -o target | default "")
if ($target | is-empty) {
$rows = ($rows | append { component: $comp_name, check: "target_server", status: "WARN", detail: "mode=taskserv but no target specified" })
} else if ($server_names | is-empty) {
$rows = ($rows | append { component: $comp_name, check: "target_server", status: "SKIP", detail: $"servers.ncl not available — cannot verify '($target)'" })
} else {
let found = ($server_names | any { $in == $target })
$rows = ($rows | append {
component: $comp_name,
check: "target_server",
status: (if $found { "ok" } else { "MISSING" }),
detail: (if $found { $"target '($target)' exists" } else { $"target '($target)' not found in servers" }),
})
}
} else if $mode == "cluster" {
let sc = ($comp | get -o storage_class | default "")
if ($sc | is-not-empty) {
if ($caps_sc | is-empty) {
$rows = ($rows | append { component: $comp_name, check: "storage_class", status: "SKIP", detail: "capabilities.ncl not available" })
} else {
let found = ($caps_sc | any { $in == $sc })
$rows = ($rows | append {
component: $comp_name,
check: "storage_class",
status: (if $found { "ok" } else { "MISSING" }),
detail: (if $found { $"storage_class '($sc)' available" } else { $"storage_class '($sc)' not in capabilities" }),
})
}
}
}
# Always emit a baseline row even when no sub-checks apply.
if ($rows | where component == $comp_name | is-empty) {
$rows = ($rows | append { component: $comp_name, check: "declared", status: "ok", detail: $"mode=($mode)" })
}
}
$rows
}
# List all components declared in the workspace infra settings.
#
# Cluster components: one row per component (dag-summary state).
# Taskserv components: one row per (component × server) from the state file.
#
# Usage:
# provisioning component list --workspace libre-daoshi
# provisioning component list --server libre-wuji-wrk-0
export def "main component list" [
--workspace (-w): string = "" # Workspace name or path (default: auto-detect from CWD)
--infra (-i): string = "" # Infra name (default: auto-detect from workspace)
--mode (-m): string = "" # Filter by deploy mode: cluster | taskserv (default: all)
--server (-s): string = "" # Filter taskserv rows to a specific server hostname
]: nothing -> table<name: string, mode: string, server: string, namespace: string, version: string, state: string> {
let ws_root = (comp-resolve-ws-path $workspace)
if ($ws_root | is-empty) {
cli-err "Could not resolve workspace root"
}
let infra = if ($infra | is-not-empty) { $infra } else {
# Auto-detect: first infra dir that has settings.ncl
ls ($ws_root | path join "infra")
| where type == dir
| get name
| each { |p| $p | path basename }
| where { |n| ($ws_root | path join "infra" $n "settings.ncl" | path exists) }
| get 0?
| default ""
}
if ($infra | is-empty) {
cli-err $"No infra with settings.ncl found in ($ws_root)/infra/"
}
let settings = (comp-ncl-export $ws_root ($"infra/($infra)/settings.ncl"))
let components = ($settings | get -o components | default {})
let dag_formulas = (comp-load-dag $ws_root $infra)
let all_names = ($components | columns)
let filtered_names = if ($mode | is-not-empty) {
$all_names | where {|n|
($components | get $n | get -o mode | default "cluster") == $mode
}
} else {
$all_names
}
let state_root = (comp-state-root $ws_root $infra)
$filtered_names | each { |comp_name|
let comp = ($components | get $comp_name)
let comp_mode = ($comp | get -o mode | default "cluster")
# Service-class contract surface (step 2): class, effective resources
# (cpu·mem req/lim) and placement (node_class + mode). "—" when undeclared.
let svc_class = ($comp | get -o class | default "")
let re = ($comp | get -o resources_effective | default {})
let resources = if ($re | is-empty) { "—" } else {
let c = ($re | get -o cpu | default {})
let m = ($re | get -o memory | default {})
$"($c | get -o request | default '?')/($c | get -o limit | default '?') · ($m | get -o request | default '?')/($m | get -o limit | default '?')"
}
let pl = ($comp | get -o placement | default {})
let placement = if ($pl | is-empty) { "—" } else {
let ncs = ($pl | get -o node_class | default [] | str join ",")
if ($ncs | is-empty) { "—" } else { $"($ncs) \(($pl | get -o mode | default 'prefer')\)" }
}
if $comp_mode == "taskserv" {
# Expand: one row per server. If the declaration has an explicit target,
# restrict to that server only — prevents node-join state (same taskserv
# key, different server) from polluting the component's row.
let declared_target = ($comp | get -o target | default "")
let per_server_all = (comp-direct-state-for $state_root $comp_name)
let per_server = if ($declared_target | is-not-empty) {
$per_server_all | where server == $declared_target
} else {
$per_server_all
}
if ($per_server | is-empty) {
[{
name: $comp_name,
mode: "taskserv",
server: $declared_target,
namespace: "",
version: ($comp | get -o version | default ""),
class: $svc_class,
resources: $resources,
placement: $placement,
pod_selector: ($comp | get -o pod_selector | default ""),
live_check: ($comp | get -o live_check | default {}),
state: "pending",
}]
} else {
$per_server | each {|entry|
{
name: $comp_name,
mode: "taskserv",
server: $entry.server,
namespace: "",
version: ($comp | get -o version | default ""),
class: $svc_class,
resources: $resources,
placement: $placement,
pod_selector: ($comp | get -o pod_selector | default ""),
live_check: ($comp | get -o live_check | default {}),
state: $entry.state,
}
}
}
} else {
# Cluster: one row, dag-summary state.
let dag_raw = (comp-dag-state-for $state_root $dag_formulas $comp_name)
let dag = if ($dag_raw | is-empty) {
comp-direct-state-for $state_root $comp_name
} else {
$dag_raw
}
[{
name: $comp_name,
mode: "cluster",
server: "",
namespace: ($comp | get -o namespace | default ""),
version: ($comp | get -o version | default ""),
class: $svc_class,
resources: $resources,
placement: $placement,
pod_selector: ($comp | get -o pod_selector | default ""),
live_check: ($comp | get -o live_check | default {}),
state: (comp-dag-summary $dag),
}]
}
} | flatten
| if ($server | is-not-empty) {
where {|r| ($r.mode == "cluster") or ($r.server == $server) }
} else { $in }
}
# Show the full unified view of a single component declaration.
#
# Exports infra/{infra}/components/{name}.ncl from the workspace. If that file
# does not exist, falls back to the component entry in settings.ncl.
# Returns a record with mode, target, namespace, requires, provides, and operations.
#
# Usage:
# provisioning component info postgresql --workspace libre-daoshi --infra wuji
# Load a component record + DAG state for use by the status subcommand and info printer.
# Returns a record with: mode, target, namespace, version, pod_selector, live_check,
# requires (normalized list), operations (enabled list), provides, dag (state entries).
export def comp-load-component-record [
name: string
ws_root: string
infra: string
]: nothing -> record {
let comp_ncl_path = ($ws_root | path join "infra" $infra "components" $"($name).ncl")
let comp = if ($comp_ncl_path | path exists) {
let raw = (comp-ncl-export $ws_root ($"infra/($infra)/components/($name).ncl"))
$raw | get -o $name | default $raw
} else {
let settings = (comp-ncl-export $ws_root ($"infra/($infra)/settings.ncl"))
let components = ($settings | get -o components | default {})
if not ($name in ($components | columns)) {
cli-err $"Component '($name)' not found in settings.ncl or components/$name.ncl"
}
$components | get $name
}
let dag_formulas = (comp-load-dag $ws_root $infra)
let state_root = (comp-state-root $ws_root $infra)
let dag_raw = (comp-dag-state-for $state_root $dag_formulas $name)
let dag = if ($dag_raw | is-empty) { comp-direct-state-for $state_root $name } else { $dag_raw }
let req_raw = ($comp | get -o requires | default {})
let req_norm = if ($req_raw | describe | str starts-with "record") {
[
(if ($req_raw | get -o storage | default null | is-not-empty) {
let s = ($req_raw.storage? | default {})
[$"storage:($s | get -o size | default '?')"]
} else { [] }),
(if ($req_raw | get -o ports | default [] | is-not-empty) {
let ports = ($req_raw.ports? | default [] | each {|p| $"($p | get -o port | default '?')"} | str join "/")
[$"ports:($ports)"]
} else { [] }),
($req_raw | get -o credentials | default []),
] | flatten
} else if ($req_raw | describe | str starts-with "list") { $req_raw } else { [] }
let ops_raw = ($comp | get -o operations | default {})
let ops_norm = if ($ops_raw | describe | str starts-with "record") {
$ops_raw | transpose k v | where {|r| $r.v == true } | each {|r| $r.k}
} else if ($ops_raw | describe | str starts-with "list") { $ops_raw } else { [] }
{
mode: ($comp | get -o mode | default "cluster"),
target: ($comp | get -o target | default ""),
namespace: ($comp | get -o namespace | default ""),
version: ($comp | get -o version | default ""),
pod_selector: ($comp | get -o pod_selector | default ""),
live_check: ($comp | get -o live_check | default {}),
requires: $req_norm,
operations: $ops_norm,
provides: ($comp | get -o provides | default {}),
dag: $dag,
}
}
export def "main component info" [
name: string # Component name
--workspace (-w): string = "" # Workspace name (default: active)
--infra (-i): string = "" # Infra name (default: auto-detect)
]: nothing -> nothing {
let ws_root = (comp-resolve-ws-path $workspace)
if ($ws_root | is-empty) {
cli-err "Could not resolve workspace root"
}
let infra = if ($infra | is-not-empty) { $infra } else {
ls ($ws_root | path join "infra")
| where type == dir
| get name
| each { |p| $p | path basename }
| where { |n| ($ws_root | path join "infra" $n "settings.ncl" | path exists) }
| get 0?
| default ""
}
if ($infra | is-empty) {
cli-err $"No infra with settings.ncl found in ($ws_root)/infra/"
}
let info = (comp-load-component-record $name $ws_root $infra)
let comp = do {
let comp_ncl_path = ($ws_root | path join "infra" $infra "components" $"($name).ncl")
if ($comp_ncl_path | path exists) {
let raw = (comp-ncl-export $ws_root ($"infra/($infra)/components/($name).ncl"))
$raw | get -o $name | default $raw
} else {
let settings = (comp-ncl-export $ws_root ($"infra/($infra)/settings.ncl"))
($settings | get -o components | default {}) | get -o $name | default {}
}
}
let dag = $info.dag
let req_norm = $info.requires
let ops_norm = $info.operations
let provides_raw = $info.provides
print $"component ($name)"
print $"infra ($infra)"
print $"mode ($info.mode)"
if ($info.version | is-not-empty) { print $"version ($info.version)" }
if ($info.namespace | is-not-empty) { print $"namespace ($info.namespace)" }
if ($info.target | is-not-empty) { print $"target ($info.target)" }
if ($info.pod_selector | is-not-empty) { print $"pod_selector ($info.pod_selector)" }
print ""
print "requires"
for $r in $req_norm { print $" ($r)" }
if ($req_norm | is-empty) { print " (none)" }
print ""
print "provides"
if ($provides_raw | columns | is-not-empty) {
$provides_raw | transpose k v | each {|r| print $" ($r.k) ($r.v)"} | ignore
} else {
print " (none)"
}
print ""
print "operations"
for $op in $ops_norm { print $" ($op)" }
if ($ops_norm | is-empty) { print " (none)" }
if ($dag | is-not-empty) {
print ""
print $"DAG execution — ($dag | length) formulas:"
for $step in $dag {
let state_icon = match $step.state {
"completed" => "✅", "running" => "🔄",
"failed" => "❌", "blocked" => "⊘", _ => "⏳",
}
let blocker = if ($step.blocker | is-not-empty) { $" ← blocked by ($step.blocker)" } else { "" }
print $" ($state_icon) ($step.formula | fill -a l -w 28) server: ($step.server) ($step.state)($blocker)"
let entries = ($step.log? | default [])
if ($entries | is-empty) {
print " history: —"
} else {
for $ev in $entries {
let ev_icon = match $ev.event {
"completed" => "✅",
"started" => "▶",
"failed" => "❌",
"reset-reprovision" => "↺",
_ => "·",
}
let ts = ($ev.ts | str replace "T" " " | str replace "Z" "")
print $" ($ev_icon) ($ts) ($ev.event)"
}
}
}
}
}
# ── Lifecycle operation dispatcher ───────────────────────────────────────────
# Fan-out dispatcher: when server is empty, expands to all active servers from state.
# Falls back to a single call with an empty target when no active servers are found
# (preserves existing resolution logic in component_workflow for new installs).
def component_dispatch [
ws_root: string
infra: string
name: string
op: string
server: string
check: bool
wait: bool
timeout: int
orchestrator: string
no_orch: bool
debug: bool
]: nothing -> list<string> {
use domain/workspace/state.nu [state-component-active-servers]
let infra_name = ($infra | path basename)
let targets = if ($server | is-not-empty) {
[$server]
} else {
let active = (state-component-active-servers $ws_root $name)
if ($active | is-not-empty) { $active } else { [""] }
}
mut results = []
for t in $targets {
$results = ($results | append (component_workflow $ws_root $infra_name $name $op $t $check $wait $timeout $orchestrator $no_orch $debug))
}
$results
}
# Build the ComponentWorkflow payload and submit it via the orchestrator client.
# Returns the task_id. --wait blocks until the task reaches a terminal state.
# no_orch = true: bypass orchestrator, execute directly via the taskserv runner.
# Used by the orchestrator itself to avoid the component→orchestrator loop.
def component_workflow [
ws_root: string
infra: string
name: string
op: string
server: string
check: bool
wait: bool
timeout: int
orchestrator: string
no_orch: bool = false
debug: bool = false
]: nothing -> string {
let fs_name = ($name | str replace --all "-" "_")
# State key is always kebab — DAG formula taskserv.name convention. Using a single canonical
# form prevents duplicate entries when CLI passes snake and orchestrator passes kebab for the
# same component.
let state_key = ($name | str replace --all "_" "-")
let settings = (comp-ncl-export $ws_root ($"infra/($infra)/settings.ncl"))
let comp = ($settings | get -o components | default {} | get -o $fs_name | default {})
let mode = ($comp | get -o mode | default "cluster")
let ns = ($comp | get -o namespace | default "")
let target = if ($server | is-not-empty) {
$server
} else if $mode == "taskserv" {
let explicit = ($comp | get -o target | default "")
if ($explicit | is-not-empty) {
$explicit
} else {
$settings | get -o servers | default [] | get 0? | default {} | get -o hostname | default ""
}
} else {
let formulas = (comp-load-dag $ws_root $infra)
if ($formulas | is-not-empty) {
$formulas | get 0? | default {} | get -o server | default ""
} else {
$settings | get -o servers | default [] | get 0? | default {} | get -o hostname | default ""
}
}
# Auto-redirect: if the component declares a target and the install target differs,
# look for a {name}_worker variant in settings and use it instead.
let comp_target = ($comp | get -o target | default "")
let name = if ($comp_target | is-not-empty) and ($comp_target != $target) {
let worker_key = $"($fs_name)_worker"
let has_worker = ($settings | get -o components | default {} | get -o $worker_key | default null) != null
if $has_worker {
print $"↪ component ($name) target=($comp_target) ≠ ($target) — using ($worker_key)"
$worker_key
} else { $name }
} else { $name }
if $no_orch {
# Direct execution path: fallback when orchestrator is unavailable.
print $"Executing component ($op) directly: ($name) → ($target)"
match $mode {
"taskserv" => {
use orchestration/taskservs/create.nu *
let infra_path = ($ws_root | path join "infra" $infra)
main create $name $target --infra $infra_path --check=$check
}
_ => {
cli-err $"Direct execution not yet supported for deploy mode '($mode)' \(component: ($name)\)"
}
}
return "direct"
}
if ($target | is-empty) {
cli-err $"Cannot resolve target server for component '($name)' — set a target in settings or pass --server"
}
let ssh_info = (comp-server-ssh-info $ws_root $infra $target)
let prov_root = (comp-prov-root)
# Pre-flight: build + validate the bundle BEFORE any op tracking or state changes.
# Errors here are clean exits — no jj/rad/op-log/state-node noise.
print $"[preflight] Validating component bundle: ($name) → ($target)"
let bundle_result = if $mode == "cluster" {
comp-build-cluster-bundle $name $ws_root $infra $target $op $settings $debug
} else {
let b64 = (comp-build-bundle $name $ws_root $infra $target $op $settings)
{b64: $b64, work_dir: ""}
}
if $check {
print $"[preflight] OK — bundle validated. check_mode=true, not submitting."
if $debug { print $"[preflight] work_dir preserved: ($bundle_result.work_dir)" }
if ($bundle_result.work_dir | is-not-empty) and (not $debug) { ^rm -rf $bundle_result.work_dir }
return "check"
}
let payload = {
workspace: $ws_root,
infra: $infra,
component: $name,
server: $ssh_info.host,
namespace: (if ($ns | is-not-empty) { $ns } else { null }),
ssh_user: $ssh_info.user,
ssh_key_path: (if ($ssh_info.key | is-not-empty) { $ssh_info.key } else { null }),
settings: ($settings | to json),
check_mode: $check,
debug: $debug,
provisioning: $prov_root,
script_compressed: $bundle_result.b64,
mode: $mode,
}
# Service health gates BEFORE any state mutation — clean exit with no side effects on failure.
health-gate $"component ($name) ($op)"
let orch_url = (orch-url --orchestrator $orchestrator)
if not (orch-health --orchestrator $orchestrator) {
cli-err $"Orchestrator is not running at ($orch_url)\nStart it with: nu provisioning/platform/scripts/start-provisioning-daemon.nu"
}
# All gates passed — safe to record op start now.
let actor = ($env.USER? | default "system")
let effective_op = match $op {
"install" | "reinstall" => "create",
$o => $o,
}
let state_root = (comp-state-root $ws_root $infra)
state-node-start $state_root $target $state_key --actor $actor --source "cli" --operation $effective_op
print $"Submitting component ($op): ($name) → ($target)"
let task_id = (orch-submit-component $payload $op --orchestrator $orchestrator)
if $wait {
let final = (orch-wait-task $task_id --timeout $timeout --orchestrator $orchestrator)
let status = ($final | get -o status | default "unknown" | str downcase)
match $status {
"completed" => {
match $op {
"delete" => {
state-node-delete $state_root $target $state_key
},
"reinstall" => {
state-node-reset $state_root $target $state_key --source "cli" --actor $actor
},
_ => {
state-node-finish $state_root $target $state_key --success --source "cli"
},
}
# Provisioning-side DNS reconcile — nodes carry no DNS tooling, so the
# A record(s) are upserted here with native http/json after a successful
# install. Never CRASHES the deploy over DNS, but the outcome is captured
# and a miss is surfaced loudly (not the old silent `do -i`): cloudflare-dns.nu
# verifies the record and exits non-zero when it is not confirmed.
# DNS-degraded tracking (ADR-051 postop-degrades-and-notifies): the
# WARN sites below no longer just print — a failed verify degrades the
# op. component_dispatch owns no op record (op start/finish are separate
# operator calls), so the degrade surfaces as: a [DEGRADED] log line
# (always), an op.json field (when PROVISIONING_OP_ID is exported), and
# a non-zero exit when ops.dns.strict = true (default false = WARN-only).
mut dns_degraded = false
mut dns_degraded_detail = ""
if $mode == "cluster" and $op != "delete" {
let dns = ($bundle_result | get -o dns | default {})
let dns_mod = ($prov_root | path join "catalog" "lib" "cloudflare-dns.nu")
let dns_domain = ($dns | get -o domain | default "")
let dns_ip = ($dns | get -o target_ip | default "")
# Domain declared but no resolved target — the reconcile cannot publish.
# Do not hide it (this was the ontoref silent skip: gateway_ip empty).
if ($dns_domain | is-not-empty) and ($dns_ip | is-empty) {
print $" [WARN] DNS: ($dns_domain) has no resolved target_ip (gateway_ip empty) — A record NOT published; the domain will not resolve. Check gateway_fip / hcloud auth."
$dns_degraded = true
$dns_degraded_detail = $"($dns_domain): no resolved target_ip (gateway_ip empty)"
}
if ($dns_domain | is-not-empty) and ($dns_ip | is-not-empty) {
let dns_r = (do {
with-env { CF_API_TOKEN: ($dns | get -o token | default "") } {
^nu $dns_mod $dns_domain $dns_ip ($dns | get -o zone | default "")
}
} | complete)
if not ($dns_r.stdout | str trim | is-empty) { print ($dns_r.stdout | str trim) }
if $dns_r.exit_code != 0 {
print $" [WARN] DNS reconcile NOT confirmed for ($dns_domain) → ($dns_ip); deploy continued but the domain may not resolve — publish it or re-run the op."
$dns_degraded = true
$dns_degraded_detail = $"($dns_domain) → ($dns_ip): reconcile not confirmed"
}
}
let dns_sites = ($dns | get -o sites | default [])
if ($dns_sites | is-not-empty) {
let tmp = (mktemp --suffix ".json")
$dns_sites | to json | save --force $tmp
let many_r = (do { ^nu $dns_mod many $tmp } | complete)
if not ($many_r.stdout | str trim | is-empty) { print ($many_r.stdout | str trim) }
if $many_r.exit_code != 0 {
print " [WARN] DNS multi-site reconcile reported unconfirmed records — see output above."
$dns_degraded = true
$dns_degraded_detail = "multi-site reconcile: unconfirmed records"
}
rm -f $tmp
}
}
# Delete-path DNS inversion (ADR-051 delete-inverts-cleanup): the
# install/update reconcile above publishes the A record, so delete
# must remove it or the component leaves an orphaned record. Runs on
# the provisioner (token locality), symmetric with the upsert path.
if $mode == "cluster" and $op == "delete" {
let dns = ($bundle_result | get -o dns | default {})
let dns_domain = ($dns | get -o domain | default "")
if ($dns_domain | is-not-empty) {
let dns_mod = ($prov_root | path join "catalog" "lib" "cloudflare-dns.nu")
let del_r = (do {
with-env { CF_API_TOKEN: ($dns | get -o token | default "") } {
^nu $dns_mod delete $dns_domain ($dns | get -o zone | default "")
}
} | complete)
if not ($del_r.stdout | str trim | is-empty) { print ($del_r.stdout | str trim) }
if $del_r.exit_code != 0 {
print $" [WARN] DNS delete NOT confirmed for ($dns_domain); the A record may be orphaned — remove it manually or re-run the delete."
$dns_degraded = true
$dns_degraded_detail = $"($dns_domain): delete not confirmed (A record may be orphaned)"
}
}
}
# Postdeploy escalation (ADR-051 postop-degrades-and-notifies): a
# degraded DNS state must be observable + recorded, never silently green.
if $dns_degraded {
print $" [DEGRADED] dns: ($dns_degraded_detail)"
# Persist to the op record only when the wrapping op exported its id
# (dispatch has no op context of its own). No-op when absent/missing.
let op_id_env = ($env.PROVISIONING_OP_ID? | default "")
if ($op_id_env | is-not-empty) {
let op_json = ($ws_root | path join "infra" $infra "ops" $op_id_env "op.json")
if ($op_json | path exists) {
let rec = (open $op_json)
$rec | merge { degraded: { dns: $dns_degraded_detail } } | to json --indent 2 | save --force $op_json
print $" [DEGRADED] recorded in op ($op_id_env)"
}
}
# ops.dns.strict = true → fail the process so the wrapping op is
# marked failed. Default false preserves today's WARN-only behavior
# for all workspaces that have not opted in.
let prov_cfg = (try { comp-ncl-export $ws_root "config/provisioning.ncl" } catch { {} })
let dns_strict = ($prov_cfg | get -o ops.dns.strict | default false)
if $dns_strict {
error make {msg: $"Op degraded — DNS not confirmed: ($dns_degraded_detail) \(ops.dns.strict=true\)"}
}
}
print $"Done: ($status)"
},
_ => {
state-node-finish $state_root $target $state_key --source "cli"
cli-err $"Component ($op) failed: ($final | get -o error | default 'no detail')"
},
}
}
$task_id
}
# Install a component on the target server.
export def "main component install" [
name: string
--workspace (-w): string = ""
--infra (-i): string = ""
--server (-s): string = ""
--check
--wait
--timeout: int = 300
--orchestrator: string = ""
--no-orchestrator # bypass orchestrator, execute directly
--debug (-x) # bash -x tracing on remote; leaves /tmp/{name} intact for inspection
]: nothing -> list<string> {
let ws_root = (comp-resolve-ws-path $workspace)
let infra_name = if ($infra | is-not-empty) { $infra } else { comp-ws-infra $ws_root }
component_dispatch $ws_root $infra_name $name "install" $server $check $wait $timeout $orchestrator $no_orchestrator $debug
}
# Delete a component from the target server.
export def "main component delete" [
name: string
--workspace (-w): string = ""
--infra (-i): string = ""
--server (-s): string = ""
--check
--wait
--timeout: int = 300
--orchestrator: string = ""
--no-orchestrator
--debug (-x)
]: nothing -> list<string> {
let ws_root = (comp-resolve-ws-path $workspace)
let infra_name = if ($infra | is-not-empty) { $infra } else { comp-ws-infra $ws_root }
component_dispatch $ws_root $infra_name $name "delete" $server $check $wait $timeout $orchestrator $no_orchestrator $debug
}
# Update a component to the latest declared version.
export def "main component update" [
name: string
--workspace (-w): string = ""
--infra (-i): string = ""
--server (-s): string = ""
--check
--wait
--timeout: int = 300
--orchestrator: string = ""
--no-orchestrator
--debug (-x)
]: nothing -> list<string> {
let ws_root = (comp-resolve-ws-path $workspace)
let infra_name = if ($infra | is-not-empty) { $infra } else { comp-ws-infra $ws_root }
component_dispatch $ws_root $infra_name $name "update" $server $check $wait $timeout $orchestrator $no_orchestrator $debug
}
# Reinstall a component (delete + install in one operation).
export def "main component reinstall" [
name: string
--workspace (-w): string = ""
--infra (-i): string = ""
--server (-s): string = ""
--check
--wait
--timeout: int = 300
--orchestrator: string = ""
--no-orchestrator
--debug (-x)
]: nothing -> list<string> {
let ws_root = (comp-resolve-ws-path $workspace)
let infra_name = if ($infra | is-not-empty) { $infra } else { comp-ws-infra $ws_root }
component_dispatch $ws_root $infra_name $name "reinstall" $server $check $wait $timeout $orchestrator $no_orchestrator $debug
}
# Restart a running component.
export def "main component restart" [
name: string
--workspace (-w): string = ""
--infra (-i): string = ""
--server (-s): string = ""
--wait
--timeout: int = 120
--orchestrator: string = ""
--no-orchestrator
--debug (-x)
]: nothing -> list<string> {
let ws_root = (comp-resolve-ws-path $workspace)
let infra_name = if ($infra | is-not-empty) { $infra } else { comp-ws-infra $ws_root }
component_dispatch $ws_root $infra_name $name "restart" $server false $wait $timeout $orchestrator $no_orchestrator $debug
}
# Run a backup of a component's persistent state.
export def "main component backup" [
name: string
--workspace (-w): string = ""
--infra (-i): string = ""
--server (-s): string = ""
--wait
--timeout: int = 600
--orchestrator: string = ""
--no-orchestrator
--debug (-x)
]: nothing -> list<string> {
let ws_root = (comp-resolve-ws-path $workspace)
let infra_name = if ($infra | is-not-empty) { $infra } else { comp-ws-infra $ws_root }
component_dispatch $ws_root $infra_name $name "backup" $server false $wait $timeout $orchestrator $no_orchestrator $debug
}
# Restore a component from its most recent backup.
export def "main component restore" [
name: string
--workspace (-w): string = ""
--infra (-i): string = ""
--server (-s): string = ""
--check
--wait
--timeout: int = 600
--orchestrator: string = ""
--no-orchestrator
--debug (-x)
]: nothing -> list<string> {
let ws_root = (comp-resolve-ws-path $workspace)
let infra_name = if ($infra | is-not-empty) { $infra } else { comp-ws-infra $ws_root }
component_dispatch $ws_root $infra_name $name "restore" $server $check $wait $timeout $orchestrator $no_orchestrator $debug
}
# Render a component bundle locally for audit — no ops, no SSH, no deployment.
# Outputs rendered manifests and generated run-*.sh scripts to <out_dir>/<component>/.
# Useful for reviewing what would be deployed before committing to an operation.
export def "main component render" [
name: string
--workspace (-w): string = ""
--infra (-i): string = ""
--out (-o): string = "audit" # output directory (relative to cwd)
--op: string = "init" # which op's plan scripts to include (init|update|delete|restart)
--bundle # also write <name>.tar.gz.b64 alongside rendered files
]: nothing -> nothing {
let ws_root = (comp-resolve-ws-path $workspace)
let infra_name = if ($infra | is-not-empty) { $infra } else { comp-ws-infra $ws_root }
let settings = (comp-ncl-export $ws_root $"infra/($infra_name)/settings.ncl")
let fs_name = ($name | str replace --all "-" "_")
let mode = ($settings | get -o components | default {} | get -o $fs_name | default {} | get -o mode | default "cluster")
let bundle_result = if $mode == "cluster" {
comp-build-cluster-bundle $name $ws_root $infra_name "" $op $settings false true
} else {
let b64 = (comp-build-bundle $name $ws_root $infra_name "" $op $settings)
{b64: $b64, work_dir: ""}
}
let bundle_b64 = $bundle_result.b64
if ($bundle_result.work_dir | is-not-empty) { ^rm -rf $bundle_result.work_dir }
let out_dir = ($out | path expand | path join $name)
^mkdir -p $out_dir
let tmp_tar = (^mktemp | str trim)
$bundle_b64 | ^base64 -d | save --force $tmp_tar
^tar -xzf $tmp_tar -C $out_dir
^rm -f $tmp_tar
if $bundle {
let b64_path = ($out_dir | path join $"($name).tar.gz.b64")
$bundle_b64 | save --force $b64_path
print $"Bundle archive → ($b64_path)"
}
print $"Rendered bundle → ($out_dir)"
print $"Contents:"
^find $out_dir -type f | lines | sort | each {|f| print $" ($f)" }
}
# Check for available updates for a component.
export def "main component check-updates" [
name: string
--workspace (-w): string = ""
--infra (-i): string = ""
--server (-s): string = ""
--orchestrator: string = ""
--no-orchestrator
--debug (-x)
]: nothing -> list<string> {
let ws_root = (comp-resolve-ws-path $workspace)
let infra_name = if ($infra | is-not-empty) { $infra } else { comp-ws-infra $ws_root }
component_dispatch $ws_root $infra_name $name "check-updates" $server false false 60 $orchestrator $no_orchestrator $debug
}
# Mark a component as completed in the provisioning state, with full attribution.
#
# Use when a component was deployed manually, by an agent, or when the orchestrator
# lost the completion callback (state stuck in 'running with ended_at="").
# Records actor identity, timestamp, and an optional verification note in the log.
#
# Usage:
# provisioning component mark-complete postgresql --note "verified via kubectl get sts -n data"
export def "main component mark-complete" [
name: string
--workspace (-w): string = ""
--infra (-i): string = ""
--server (-s): string = ""
--note: string = ""
]: nothing -> nothing {
let ws_root = (comp-resolve-ws-path $workspace)
let infra_name = if ($infra | is-not-empty) { $infra } else { comp-ws-infra $ws_root }
let state_root = (comp-state-root $ws_root $infra_name)
let actor = ($env.USER? | default "system")
let normalized = ($name | str replace --all "_" "-")
# Resolve server: explicit flag → state-file scan → first DAG formula server.
let target = if ($server | is-not-empty) {
$server
} else {
# Scan state file for any server that has this component registered.
let st = (state-read $state_root)
let from_state = (
$st.servers
| transpose hostname srv
| where {|row|
let ts = ($row.srv.taskservs? | default {})
($ts | get -o $name | default {} | is-not-empty) or ($ts | get -o $normalized | default {} | is-not-empty)
}
| get 0?
| get -o hostname
| default ""
)
if ($from_state | is-not-empty) {
$from_state
} else {
# Fall back to first DAG formula server that contains this component node.
let dag_server = (
comp-load-dag $ws_root $infra_name
| each {|f|
let matched = ($f.nodes | where {|n| (comp-node-name $n) == $normalized })
if ($matched | is-not-empty) { [$f.server] } else { [] }
}
| flatten | get 0? | default ""
)
$dag_server
}
}
if ($target | is-empty) {
cli-err $"Cannot resolve server for component '($name)' — pass --server"
}
# Canonical key is kebab (matches DAG formula taskserv.name convention and the new
# state_key normalization in component_workflow). Prefer $normalized (kebab) when both
# forms are present — avoids leaving the DAG-tracked key stale after mark-complete.
let server_ts = (state-read $state_root | get -o servers | default {} | get -o $target | default {} | get -o taskservs | default {})
let active_key = if ($server_ts | get -o $normalized | default {} | is-not-empty) {
$normalized
} else {
$name
}
let ts = ((date now) | format date "%Y-%m-%dT%H:%M:%SZ")
let existing = (state-node-get $state_root $target $active_key)
let event_label = if ($note | is-not-empty) { $"mark-complete: ($note)" } else { "mark-complete" }
let updated_log = (log-trim ($existing.log? | default [] | append {
ts: $ts, event: $event_label, source: "cli"
}))
state-node-set $state_root $target $active_key {
state: "completed",
ended_at: $ts,
actor: { identity: $actor, source: "cli" },
log: $updated_log,
}
print $"component ($name)"
print $"server ($target)"
print $"actor ($actor)"
print $"timestamp ($ts)"
if ($note | is-not-empty) { print $"note ($note)" }
print "state completed"
}
# Sweep all stale components (state=running with empty ended_at) to completed in one write.
#
# Use after an orchestrator crash or manual deploy session where callbacks were lost.
# Pass --include-failed to also sweep 'failed' entries (e.g. cilium/longhorn that failed
# on first script attempt but are actually running — verify pods before using this flag).
# Pass --dry-run to preview without writing.
export def "main component bulk-mark-complete" [
--workspace (-w): string = ""
--infra (-i): string = ""
--note: string = ""
--include-failed # Also sweep state=failed entries
--dry-run # Print what would change, no writes
]: nothing -> nothing {
let ws_root = (comp-resolve-ws-path $workspace)
let infra_name = if ($infra | is-not-empty) { $infra } else { comp-ws-infra $ws_root }
let state_root = (comp-state-root $ws_root $infra_name)
let actor = ($env.USER? | default "system")
let ts = ((date now) | format date "%Y-%m-%dT%H:%M:%SZ")
mut st = (state-read $state_root)
# Collect all stale (server, key) pairs from the in-memory record.
let stale = (
$st.servers
| transpose hostname srv
| each {|row|
$row.srv.taskservs?
| default {}
| transpose name node
| where {|e|
let s = ($e.node | get -o state | default "pending")
let ended = ($e.node | get -o ended_at | default "")
if $include_failed {
($s == "running" and $ended == "") or $s == "failed"
} else {
$s == "running" and $ended == ""
}
}
| each {|e| { hostname: $row.hostname, name: $e.name, state: ($e.node | get -o state | default "") } }
}
| flatten
)
if ($stale | is-empty) {
print "No stale entries found."
return
}
print $"Found ($stale | length) stale entries:"
$stale | each {|e| print $" ($e.hostname) ($e.name) [($e.state)]" }
if $dry_run { return }
let event_label = if ($note | is-not-empty) { $"mark-complete: ($note)" } else { "bulk-mark-complete" }
# Mutate in memory, single write at the end.
# canonical_key is always kebab — matches DAG formula convention.
for e in $stale {
let canonical_key = ($e.name | str replace --all "_" "-")
let current_server = (
$st.servers
| transpose k v
| where { |r| $r.k == $e.hostname }
| get -o v.0
| default { provider_id: "", provider_state: "unknown", last_sync: "", taskservs: {} }
)
# Merge logs from both the original key and the canonical key (if they differ)
# so no history is lost when normalizing snake → kebab.
let original_node = (
$current_server.taskservs?
| default {}
| transpose k v
| where { |r| $r.k == $e.name }
| get -o v.0
| default { state: "pending", operation: "create", profile: "", started_at: "", ended_at: "", blocker: "", actor: { identity: "", source: "cli" }, log: [] }
)
let canonical_node = if $canonical_key != $e.name {
$current_server.taskservs?
| default {}
| transpose k v
| where { |r| $r.k == $canonical_key }
| get -o v.0
| default {}
} else { {} }
let merged_log = (
($original_node.log? | default [])
| append ($canonical_node | get -o log | default [])
| uniq-by ts
| sort-by ts
)
let base_node = if ($canonical_node | is-not-empty) { $canonical_node } else { $original_node }
let updated_log = (log-trim ($merged_log | append { ts: $ts, event: $event_label, source: "cli" }))
let merged = ($base_node | merge {
state: "completed",
ended_at: $ts,
actor: { identity: $actor, source: "cli" },
log: $updated_log,
})
# Write to canonical key, delete snake duplicate if it differed.
mut new_ts = ($current_server.taskservs? | default {} | upsert $canonical_key $merged)
if $canonical_key != $e.name and ($new_ts | columns | any { $in == $e.name }) {
$new_ts = ($new_ts | reject $e.name)
}
let new_server = ($current_server | upsert taskservs $new_ts)
let new_servers = (
$st.servers
| transpose k v
| each { |r| if $r.k == $e.hostname { { k: $r.k, v: $new_server } } else { $r } }
| transpose -r -d
)
$st.servers = $new_servers
}
state-write $state_root $st
print ""
$stale | each {|e|
let canonical_key = ($e.name | str replace --all "_" "-")
if $canonical_key != $e.name {
print $"✓ ($e.hostname) ($e.name) → ($canonical_key) [normalized]"
} else {
print $"✓ ($e.hostname) ($e.name)"
}
}
print $"\nMarked ($stale | length) components as completed."
}
# Drop a specific state key from a server's taskservs — prunes duplicate or orphaned entries.
#
# Use when the same component exists under two keys (e.g. both 'odoo-jesusperez' and
# 'odoo_jesusperez' after a naming migration). Identify the stale key with 'prvng c ll'
# then drop it here. The surviving key retains its full history.
export def "main component drop-key" [
name: string # Exact key to delete from state
--server (-s): string # Target server hostname (required — no auto-resolution)
--workspace (-w): string = ""
--infra (-i): string = ""
--dry-run
]: nothing -> nothing {
if ($server | is-empty) {
cli-err "Pass --server <hostname> — no auto-resolution for drop-key (safety measure)"
}
let ws_root = (comp-resolve-ws-path $workspace)
let infra_name = if ($infra | is-not-empty) { $infra } else { comp-ws-infra $ws_root }
let state_root = (comp-state-root $ws_root $infra_name)
let st = (state-read $state_root)
let existing = ($st.servers | get -o $server | default {} | get -o taskservs | default {} | get -o $name | default null)
if ($existing | is-empty) {
cli-err $"Key '($name)' not found on server '($server)'"
}
print $"Will drop: ($server) ($name) [state=($existing | get -o state | default '?')]"
if $dry_run { return }
state-node-delete $state_root $server $name
print "Dropped."
}
# Remove an entire server block from the state file.
#
# Use when a server has been decommissioned and its state entries are orphaned.
# All component states for that server are deleted in one operation.
export def "main component drop-server" [
hostname: string
--workspace (-w): string = ""
--infra (-i): string = ""
--dry-run
]: nothing -> nothing {
let ws_root = (comp-resolve-ws-path $workspace)
let infra_name = if ($infra | is-not-empty) { $infra } else { comp-ws-infra $ws_root }
let state_root = (comp-state-root $ws_root $infra_name)
let st = (state-read $state_root)
let srv = ($st.servers | get -o $hostname | default null)
if ($srv | is-empty) {
cli-err $"Server '($hostname)' not found in state"
}
let taskserv_count = ($srv | get -o taskservs | default {} | columns | length)
print $"Will drop server: ($hostname) [($taskserv_count) taskserv entries]"
if $dry_run { return }
let new_servers = (
$st.servers
| transpose k v
| where { $in.k != $hostname }
| each {|r| { ($r.k): $r.v } }
| reduce --fold {} {|a, acc| $acc | merge $a }
)
let new_st = ($st | upsert servers $new_servers)
state-write $state_root $new_st
print $"Dropped server ($hostname) with ($taskserv_count) entries."
}
# Diagnose state coherence: lists components whose provisioning state doesn't match
# live pod status. Outputs a table of incoherent entries so you know what to fix.
#
# Incoherent cases:
# stale-running — state=running with empty ended_at (callback lost)
# failed-but-up — state=failed but live pods found (transient failure, manually recovered)
# duplicate-keys — both kebab and snake_case key present for the same component
export def "main component state-check" [
--workspace (-w): string = ""
--infra (-i): string = ""
--server (-s): string = ""
]: nothing -> nothing {
let ws_root = (comp-resolve-ws-path $workspace)
let infra_name = if ($infra | is-not-empty) { $infra } else { comp-ws-infra $ws_root }
let state_root = (comp-state-root $ws_root $infra_name)
let st = (state-read $state_root)
let entries = (
$st.servers
| transpose hostname srv
| where {|row| if ($server | is-not-empty) { $row.hostname == $server } else { true } }
| each {|row|
$row.srv.taskservs?
| default {}
| transpose name node
| each {|e| {
hostname: $row.hostname,
name: $e.name,
state: ($e.node | get -o state | default "pending"),
ended_at: ($e.node | get -o ended_at | default ""),
}}
}
| flatten
)
# stale-running: state=running with empty ended_at
let stale_running = ($entries | where { $in.state == "running" and $in.ended_at == "" })
# failed: state=failed (may be recovered)
let failed = ($entries | where { $in.state == "failed" })
# duplicate keys: a kebab entry AND a snake entry exist for the same component on the same server
let duplicates = (
$entries
| group-by { |e| $"($e.hostname)/($e.name | str replace --all '-' '_')" }
| transpose normalized group
| where { ($in.group | length) > 1 }
| each {|row|
$row.group | each {|e| $e | merge { issue: "duplicate-key", normalized: $row.normalized } }
}
| flatten
)
print "── STALE RUNNING (callback lost) ──────────────────────────────────────"
if ($stale_running | is-empty) {
print " none"
} else {
$stale_running | select hostname name state ended_at | print
print $"\n Fix: prvng c bulk-mark-complete"
}
print "\n── FAILED (may be recovered) ──────────────────────────────────────────"
if ($failed | is-empty) {
print " none"
} else {
$failed | select hostname name state ended_at | print
print "\n Fix: prvng c mark-complete <name> --server <hostname>"
}
print "\n── DUPLICATE KEYS (kebab+snake coexist) ───────────────────────────────"
if ($duplicates | is-empty) {
print " none"
} else {
$duplicates | select hostname name state | print
print "\n Fix: prvng c drop-key <stale-key> --server <hostname>"
}
let total = (($stale_running | length) + ($failed | length) + ($duplicates | length))
print $"\nTotal issues: ($total)"
if $total > 0 {
print "\nRun 'prvng c state-normalize' to merge snake→kebab duplicates in one pass."
}
}
# Pure helper: given a taskservs record, return a new record with all snake_case keys
# merged into their kebab equivalents. No mutation — safe in closures.
def state-normalize-taskservs [ts: record]: nothing -> record {
let snake_keys = ($ts | columns | where { ($in | str contains "_") and ($in | str replace --all "_" "-") != $in })
if ($snake_keys | is-empty) { return $ts }
$snake_keys | reduce --fold $ts {|sk, acc|
let kk = ($sk | str replace --all "_" "-")
let snake_node = ($acc | get $sk)
let kebab_node = ($acc | get -o $kk | default {})
let merged_log = (
($snake_node.log? | default [])
| append ($kebab_node | get -o log | default [])
| uniq-by ts
| sort-by ts
)
let base = if ($kebab_node | is-not-empty) { $kebab_node } else { $snake_node }
let final_node = ($base | upsert log $merged_log)
$acc | upsert $kk $final_node | reject $sk
}
}
# One-shot migration: normalize all snake_case state keys to kebab-case.
#
# Merges each snake_case entry into its kebab counterpart (creating it if absent),
# preserving the full log union. After this command, every key in the state file uses
# kebab-case — consistent with the DAG formula convention and the new state_key write path.
# Run once after upgrading, then verify with 'prvng c sc'.
export def "main component state-normalize" [
--workspace (-w): string = ""
--infra (-i): string = ""
--dry-run
]: nothing -> nothing {
let ws_root = (comp-resolve-ws-path $workspace)
let infra_name = if ($infra | is-not-empty) { $infra } else { comp-ws-infra $ws_root }
let state_root = (comp-state-root $ws_root $infra_name)
let st = (state-read $state_root)
# Compute migration plan (pure — no writes).
let plan = (
$st.servers
| transpose hostname srv
| each {|row|
let ts = ($row.srv.taskservs? | default {})
$ts | columns
| where { ($in | str contains "_") and ($in | str replace --all "_" "-") != $in }
| each {|sk| { hostname: $row.hostname, from: $sk, to: ($sk | str replace --all "_" "-") } }
}
| flatten
)
if ($plan | is-empty) {
print "No snake_case keys found — state is already normalized."
return
}
print $"($plan | length) keys to normalize:"
$plan | each {|m| print $" ($m.hostname) ($m.from) → ($m.to)" }
if $dry_run { return }
# Build new servers record purely: normalize taskservs per server, then reassemble.
let new_servers = (
$st.servers
| transpose hostname srv
| each {|row|
let new_ts = (state-normalize-taskservs ($row.srv.taskservs? | default {}))
{ ($row.hostname): ($row.srv | upsert taskservs $new_ts) }
}
| reduce --fold {} {|a, acc| $acc | merge $a }
)
let new_st = ($st | upsert servers $new_servers)
state-write $state_root $new_st
print "\nNormalized. Run 'prvng c sc' to verify."
}