core: push cluster foundation out of cli/components.nu to proper layers (ADR-026 slice 2a)
Untangle the shared foundation that the cluster subsystem depends on, moving each helper to the layer its dependency topology dictates, de-cli-ifying on the way (cli-err → error make). One-directional imports only; verified no cycles. comp-ncl-export → tools/nickel/process.nu ws-resolution (8 fns + root finders) → domain/workspace/resolve.nu (new) comp-ssh-run, comp-server-ssh-info → platform/clients/ssh.nu (new) comp-kubectl-access, comp-kubectl-ssh → domain/cluster/access.nu (new) Consumers (components.nu, components_cluster.nu, component.nu) repointed to the new locations. components.nu 2664 → 2408. Verified: NU_LIB_DIRS module-load of all 8 touched modules (no cycle, no missing command, no export error); ADR-026 hygiene (no upward imports in the new platform/domain/tools modules); `provisioning component list` runs; component/cluster --help exit 0. Prereq for slice 2b (cluster helpers → domain/cluster/introspect.nu).
This commit is contained in:
parent
c935aa530d
commit
c22722a418
9 changed files with 282 additions and 271 deletions
|
|
@ -21,12 +21,9 @@ def cat-root [] {
|
|||
if ($prov | is-empty) {
|
||||
error make --unspanned { msg: "PROVISIONING env var not set" }
|
||||
}
|
||||
# Post-constellation-migration: env.nu may point to the constellation root
|
||||
# while the catalog lives under code/. Try direct path first, then code/ sub-repo.
|
||||
# catalog/ now lives at the provisioning root (moved out of the old code/ sub-repo).
|
||||
let direct = ($prov | path join "catalog")
|
||||
if ($direct | path exists) { return $direct }
|
||||
let via_code = ($prov | path join "code" "catalog")
|
||||
if ($via_code | path exists) { return $via_code }
|
||||
error make --unspanned { msg: $"catalog not found under ($prov) — check PROVISIONING" }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,13 +39,11 @@ use cli/components.nu [
|
|||
"main component restore"
|
||||
"main component check-updates"
|
||||
"main component render"
|
||||
comp-state-server
|
||||
comp-kubectl-access
|
||||
comp-ws-path
|
||||
comp-ws-infra
|
||||
comp-load-component-record
|
||||
comp-ncl-export
|
||||
]
|
||||
use tools/nickel/process.nu [comp-ncl-export]
|
||||
use domain/cluster/access.nu [comp-kubectl-access]
|
||||
use domain/workspace/resolve.nu [comp-state-server comp-ws-path comp-ws-infra]
|
||||
use cli/components_cluster.nu [
|
||||
comp-live-check
|
||||
comp-pods-for
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
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 tools/nickel/process.nu [ncl-eval, ncl-eval-soft, default-ncl-paths, comp-ncl-export]
|
||||
use domain/workspace/state.nu [state-read state-write state-node-get state-node-set state-node-start state-node-finish state-node-delete state-node-reset log-trim]
|
||||
use domain/workspace/resolve.nu [comp-state-server comp-ws-path comp-ws-infra comp-resolve-ws-path]
|
||||
use domain/cluster/access.nu [comp-kubectl-ssh]
|
||||
use platform/clients/ssh.nu [comp-server-ssh-info]
|
||||
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]
|
||||
|
|
@ -895,85 +898,6 @@ def comp-plan-op-to-bash [name: string, op: string, steps: list, hooks_pre: list
|
|||
$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
|
||||
}
|
||||
}
|
||||
|
||||
# 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
|
||||
}
|
||||
|
||||
# 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")
|
||||
|
|
@ -1090,86 +1014,6 @@ def comp-direct-state-for [workspace_path: string, comp_name: string]: nothing -
|
|||
} | 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.
|
||||
export 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`.
|
||||
export 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
|
||||
}
|
||||
}
|
||||
|
||||
# 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 a list of dag state entries into a single string (for list column).
|
||||
export def comp-dag-summary [dag: list]: nothing -> string {
|
||||
if ($dag | is-empty) { return "—" }
|
||||
|
|
@ -1183,104 +1027,6 @@ export def comp-dag-summary [dag: list]: nothing -> string {
|
|||
"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.
|
||||
export 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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
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 tools/nickel/process.nu [ncl-eval, ncl-eval-soft, default-ncl-paths, comp-ncl-export]
|
||||
use domain/workspace/state.nu [state-read state-write state-node-get state-node-set state-node-start state-node-finish state-node-delete state-node-reset log-trim]
|
||||
use platform/clients/orchestrator.nu [orch-url, orch-submit-component, orch-wait-task, orch-health]
|
||||
use orchestration/taskservs/run.nu [run_taskserv]
|
||||
|
|
@ -12,7 +12,10 @@ use platform/integrations/context_assembler.nu [build-cabling-credentials-env]
|
|||
|
||||
|
||||
# Cluster-observability slice extracted from components.nu (ADR-026 layer hygiene).
|
||||
use cli/components.nu [cli-err comp-ncl-export comp-ws-path comp-ws-infra comp-server-ssh-info comp-ssh-run comp-state-server comp-resolve-ws-path comp-dag-summary comp-kubectl-ssh comp-kubectl-access]
|
||||
use cli/components.nu [cli-err comp-dag-summary]
|
||||
use domain/cluster/access.nu [comp-kubectl-access comp-kubectl-ssh]
|
||||
use platform/clients/ssh.nu [comp-server-ssh-info comp-ssh-run]
|
||||
use domain/workspace/resolve.nu [comp-ws-path comp-ws-infra comp-state-server comp-resolve-ws-path]
|
||||
|
||||
# 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.
|
||||
|
|
|
|||
|
|
@ -20,10 +20,9 @@ def prov-root [] {
|
|||
if ($prov | is-empty) {
|
||||
error make --unspanned { msg: "PROVISIONING env var not set" }
|
||||
}
|
||||
# catalog/ now lives at the provisioning root (moved out of the old code/ sub-repo).
|
||||
let direct = ($prov | path join "catalog" "providers")
|
||||
if ($direct | path exists) { return $direct }
|
||||
let via_code = ($prov | path join "code" "catalog" "providers")
|
||||
if ($via_code | path exists) { return $via_code }
|
||||
error make --unspanned { msg: $"catalog/providers not found under ($prov)" }
|
||||
}
|
||||
|
||||
|
|
|
|||
65
nulib/domain/cluster/access.nu
Normal file
65
nulib/domain/cluster/access.nu
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# Cluster kube-apiserver access resolution — extracted from cli/components.nu (ADR-026 slice 2a-iii).
|
||||
use tools/nickel/process.nu [comp-ncl-export]
|
||||
use domain/workspace/resolve.nu [comp-state-server comp-ws-path comp-ws-infra]
|
||||
use platform/clients/ssh.nu [comp-server-ssh-info comp-ssh-run]
|
||||
|
||||
# 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
|
||||
}
|
||||
|
||||
131
nulib/domain/workspace/resolve.nu
Normal file
131
nulib/domain/workspace/resolve.nu
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
# Workspace path/root/state resolution — extracted from cli/components.nu (ADR-026 slice 2a-ii).
|
||||
use platform/user/config.nu [get-active-workspace-details list-workspaces]
|
||||
|
||||
# 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 ─────────────────────────────
|
||||
|
||||
# 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 ""
|
||||
}
|
||||
|
||||
# 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.
|
||||
export 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
|
||||
}
|
||||
}
|
||||
error make {msg: $"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) {
|
||||
error make {msg: "No active workspace — pass --workspace or activate one first"}
|
||||
}
|
||||
$details.path
|
||||
}
|
||||
|
||||
52
nulib/platform/clients/ssh.nu
Normal file
52
nulib/platform/clients/ssh.nu
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# SSH client primitives — extracted from cli/components.nu (ADR-026 slice 2a-iii).
|
||||
|
||||
# Get SSH connection info for a server (user, key path, host) from servers.ncl.
|
||||
export 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`.
|
||||
export 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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -93,3 +93,23 @@ export def ncl-eval-soft [
|
|||
$fallback
|
||||
}
|
||||
}
|
||||
|
||||
# Export a workspace-relative NCL file to a record, resolving import paths from the
|
||||
# workspace root. 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 {
|
||||
error make {msg: $"NCL evaluation failed for ($full_path): ($r.stderr | str trim)"}
|
||||
}
|
||||
$r.stdout | from json
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue