diff --git a/nulib/cli/catalog.nu b/nulib/cli/catalog.nu index 65d9557..8b43609 100644 --- a/nulib/cli/catalog.nu +++ b/nulib/cli/catalog.nu @@ -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" } } diff --git a/nulib/cli/component.nu b/nulib/cli/component.nu index cb8f888..61ae663 100644 --- a/nulib/cli/component.nu +++ b/nulib/cli/component.nu @@ -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 diff --git a/nulib/cli/components.nu b/nulib/cli/components.nu index b184717..313950f 100644 --- a/nulib/cli/components.nu +++ b/nulib/cli/components.nu @@ -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//` 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 -# `/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//`. - 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 diff --git a/nulib/cli/components_cluster.nu b/nulib/cli/components_cluster.nu index d2734d2..2017415 100644 --- a/nulib/cli/components_cluster.nu +++ b/nulib/cli/components_cluster.nu @@ -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. diff --git a/nulib/cli/provider.nu b/nulib/cli/provider.nu index 01d518f..f273546 100644 --- a/nulib/cli/provider.nu +++ b/nulib/cli/provider.nu @@ -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)" } } diff --git a/nulib/domain/cluster/access.nu b/nulib/domain/cluster/access.nu new file mode 100644 index 0000000..eab7d14 --- /dev/null +++ b/nulib/domain/cluster/access.nu @@ -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 +} + diff --git a/nulib/domain/workspace/resolve.nu b/nulib/domain/workspace/resolve.nu new file mode 100644 index 0000000..32923fb --- /dev/null +++ b/nulib/domain/workspace/resolve.nu @@ -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//` 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 +# `/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//`. + 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 +} + diff --git a/nulib/platform/clients/ssh.nu b/nulib/platform/clients/ssh.nu new file mode 100644 index 0000000..407e003 --- /dev/null +++ b/nulib/platform/clients/ssh.nu @@ -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 + } +} + diff --git a/nulib/tools/nickel/process.nu b/nulib/tools/nickel/process.nu index 338da47..99dc903 100644 --- a/nulib/tools/nickel/process.nu +++ b/nulib/tools/nickel/process.nu @@ -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 + } +}