- DAG architecture: `dag show/validate/export` (nulib/main_provisioning/dag.nu),
config loader (lib_provisioning/config/loader/dag.nu), taskserv dag-executor.
Backed by schemas/lib/dag/*.ncl; orchestrator emits NATS events via
WorkspaceComposition::into_workflow. See ADR-020, ADR-021.
- Unified Component Architecture: components/mod.nu, main_provisioning/
{components,workflow,extensions,ontoref-queries}.nu. Full workflow engine with
topological sort and NATS subject emission. Blocks A-H complete (libre-daoshi).
- Commands-registry: nulib/commands-registry.ncl (Nickel source, 314 lines) +
JSON cache at ~/.cache/provisioning/commands-registry.json rebuilt on source
change. cli/provisioning fast-path alias expansion avoids cold Nu startup.
ADDING_COMMANDS.md documents new-command workflow.
- Platform service manager: service-manager.nu (+573), startup.nu (+611),
service-check.nu (+255); autostart/bootstrap/health/target refactored.
- Nushell 0.112.2 migration: removed all try/catch and bash redirections;
external commands prefixed with ^; type signatures enforced. Driven by
scripts/refactor-try-catch{,-simplified}.nu.
- TTY stack: removed shlib/*-tty.sh; replaced by cli/tty-dispatch.sh,
tty-filter.sh, tty-commands.conf.
- New domain modules: images/ (golden image lifecycle), workspace/{state,sync}.nu,
main_provisioning/{bootstrap,cluster-deploy,fip,state}.nu, commands/{state,
build,integrations/auth,utilities/alias}.nu, platform.nu expanded (+874).
- Config loader overhaul: loader/core.nu slimmed (-759), cache/core.nu
refactored (-454), removed legacy loaders/file_loader.nu (-330).
- Thirteen new provisioning-<domain>.nu top-level modules for bash dispatcher.
- Tests: test_workspace_state.nu (+351); updates to test_oci_registry,
test_services.
- README + CHANGELOG updated.
421 lines
16 KiB
Text
421 lines
16 KiB
Text
use ../lib_provisioning/user/config.nu [get-workspace-path, get-active-workspace-details]
|
|
use ../../../extensions/providers/hetzner/nulib/hetzner/api.nu *
|
|
use ../lib_provisioning/utils/nickel_processor.nu [ncl-eval-soft]
|
|
|
|
# Resolve workspace root path.
|
|
# Priority: PWD config/provisioning.ncl > convention (pwd-basename) > active workspace > PWD.
|
|
def fip-ws-root []: nothing -> string {
|
|
# PWD-based detection first — user is likely in a workspace directory
|
|
let pwd_config = ($env.PWD | path join "config" "provisioning.ncl")
|
|
if ($pwd_config | path exists) {
|
|
return $env.PWD
|
|
}
|
|
# Convention: pwd basename has infra/bootstrap.ncl
|
|
if ($env.PWD | path join "infra" "bootstrap.ncl" | path exists) {
|
|
return $env.PWD
|
|
}
|
|
# Fallback: active workspace
|
|
let details = (do -i { get-active-workspace-details } | default null)
|
|
if $details != null and ($details.name? | is-not-empty) {
|
|
let p = do -i { get-workspace-path $details.name } | default ""
|
|
if ($p | is-not-empty) { return $p }
|
|
}
|
|
$env.PWD
|
|
}
|
|
|
|
# Load FIP role mapping from .provisioning-state.json.
|
|
# Returns a record keyed by FIP name → role string.
|
|
def load-fip-roles [ws_root: string]: nothing -> record {
|
|
let state_path = ($ws_root | path join ".provisioning-state.json")
|
|
if not ($state_path | path exists) { return {} }
|
|
|
|
let fips = (open --raw $state_path | from json | get -o bootstrap.floating_ips | default {})
|
|
$fips | items {|role entry|
|
|
{ key: $entry.name, value: $role }
|
|
} | reduce -f {} {|it acc| $acc | insert $it.key $it.value}
|
|
}
|
|
|
|
# Build a server_id → hostname map, cached for 5 minutes in the system temp directory.
|
|
# On cache hit: disk read only, no API call. On cache miss: fetch + write cache.
|
|
def build-server-map []: nothing -> record {
|
|
let cache_path = ($env.TMPDIR? | default "/tmp" | path join "provisioning_srv_cache.json")
|
|
|
|
if ($cache_path | path exists) {
|
|
let age = ((date now) - (ls $cache_path | first | get modified))
|
|
if $age < 5min {
|
|
return (open --raw $cache_path | from json)
|
|
}
|
|
}
|
|
|
|
let map = (
|
|
(do -i { hetzner_api_list_servers } | default [])
|
|
| reduce -f {} {|s acc| $acc | insert ($s.id | into string) $s.name}
|
|
)
|
|
$map | to json | save --force $cache_path
|
|
$map
|
|
}
|
|
|
|
# Fetch FIPs then resolve server names from cache or API.
|
|
# Server map is cached for 5 min — only FIPs are fetched live on each invocation.
|
|
def fetch-fips-and-servers []: nothing -> record {
|
|
let fips = hetzner_api_list_floating_ips
|
|
let srv_map = build-server-map
|
|
{ fips: $fips, srv_map: $srv_map }
|
|
}
|
|
|
|
# Extract location name string from a home_location field (record or string).
|
|
def extract-location [loc: any]: nothing -> string {
|
|
if $loc == null { return "" }
|
|
if ($loc | describe) == "string" { return $loc }
|
|
$loc | get -o name | default ""
|
|
}
|
|
|
|
# Extract first dns_ptr string from dns_ptr field (array of {ip, dns_ptr} or string).
|
|
def extract-dns-ptr [ptr: any]: nothing -> string {
|
|
if $ptr == null { return "" }
|
|
if ($ptr | describe) == "string" { return $ptr }
|
|
if ($ptr | describe | str starts-with "list") {
|
|
if ($ptr | is-empty) { return "" }
|
|
$ptr | first | get -o dns_ptr | default ""
|
|
} else {
|
|
""
|
|
}
|
|
}
|
|
|
|
# Format protection record as a short string.
|
|
def fmt-prot [prot: any]: nothing -> string {
|
|
if $prot == null { return "—" }
|
|
let d = ($prot | get -o delete | default false)
|
|
let r = ($prot | get -o rebuild | default false)
|
|
match [$d, $r] {
|
|
[true, true] => "del+rbld"
|
|
[true, false] => "del"
|
|
[false, true] => "rbld"
|
|
_ => "—"
|
|
}
|
|
}
|
|
|
|
# ── Private helpers ────────────────────────────────────────────────────────────
|
|
# These hold the actual logic. Both export def "main *" and def main call them,
|
|
# avoiding the Nu parser limitation with quoted-name command calls + flags.
|
|
|
|
def _fip-list [--out: string]: nothing -> nothing {
|
|
let ws_root = (fip-ws-root)
|
|
let fip_roles = (load-fip-roles $ws_root)
|
|
let fetched = (fetch-fips-and-servers)
|
|
let fips = $fetched.fips
|
|
let srv_map = $fetched.srv_map
|
|
|
|
# Load FIPs declared in bootstrap.ncl (desired state)
|
|
let bootstrap_path = ($ws_root | path join "infra" "bootstrap.ncl")
|
|
let declared_fips = if ($bootstrap_path | path exists) {
|
|
let prov_root = ($env.PROVISIONING? | default "/usr/local/provisioning")
|
|
let data = (ncl-eval-soft $bootstrap_path [$ws_root $prov_root] null)
|
|
if $data != null {
|
|
$data | get -o floating_ips | default [] | each {|f| $f.name}
|
|
} else { [] }
|
|
} else { [] }
|
|
|
|
let rows = ($fips | each {|f|
|
|
let role = ($fip_roles | get -o $f.name | default "—")
|
|
let srv_id = ($f | get -o server | default null)
|
|
let assigned = if $srv_id != null { $srv_map | get -o ($srv_id | into string) | default "—" } else { "—" }
|
|
let protection = (fmt-prot ($f | get -o protection | default null))
|
|
{
|
|
name: $f.name
|
|
ip: $f.ip
|
|
role: $role
|
|
location: (extract-location ($f | get -o home_location | default null))
|
|
assigned: $assigned
|
|
protection: $protection
|
|
dns_ptr: (extract-dns-ptr ($f | get -o dns_ptr | default null))
|
|
state: "created"
|
|
}
|
|
})
|
|
|
|
# Add declared-but-not-yet-created FIPs
|
|
let live_names = ($fips | each {|f| $f.name})
|
|
let pending = ($declared_fips | where {|n| not ($live_names | any {|l| $l == $n})}
|
|
| each {|n| {
|
|
name: $n, ip: "—", role: "—", location: "—",
|
|
assigned: "—", protection: "—", dns_ptr: "—", state: "pending bootstrap"
|
|
}}
|
|
)
|
|
let all_rows = ($rows | append $pending)
|
|
|
|
match ($out | default "") {
|
|
"json" => { print ($all_rows | to json) }
|
|
"yaml" => { print ($all_rows | to yaml) }
|
|
_ => {
|
|
if ($all_rows | is-empty) {
|
|
print "No floating IPs — declared or created."
|
|
} else {
|
|
print ($all_rows | table -i false)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
def _fip-show [name: string, --out: string]: nothing -> nothing {
|
|
let ws_root = (fip-ws-root)
|
|
let fip_roles = (load-fip-roles $ws_root)
|
|
let fetched = (fetch-fips-and-servers)
|
|
let fips = $fetched.fips
|
|
let srv_map = $fetched.srv_map
|
|
|
|
let matches = ($fips | where {|f| $f.name == $name or $f.ip == $name })
|
|
|
|
# If not in Hetzner, check if declared in bootstrap.ncl
|
|
if ($matches | is-empty) {
|
|
let bootstrap_path = ($ws_root | path join "infra" "bootstrap.ncl")
|
|
let prov_root = ($env.PROVISIONING? | default "/usr/local/provisioning")
|
|
let declared = if ($bootstrap_path | path exists) {
|
|
let data = (ncl-eval-soft $bootstrap_path [$ws_root $prov_root] null)
|
|
if $data != null {
|
|
$data | get -o floating_ips | default [] | where {|f| $f.name == $name}
|
|
} else { [] }
|
|
} else { [] }
|
|
|
|
if ($declared | is-empty) {
|
|
error make { msg: $"Floating IP '($name)' not found in Hetzner or bootstrap.ncl" }
|
|
}
|
|
let d = ($declared | first)
|
|
let detail = {
|
|
name: $d.name
|
|
ip: "— (not created)"
|
|
state: "pending bootstrap"
|
|
type: ($d.type? | default "ipv4")
|
|
home_location: ($d.location? | default "—")
|
|
description: ($d.description? | default "—")
|
|
labels: ($d.labels? | default {})
|
|
}
|
|
match ($out | default "") {
|
|
"json" => { print ($detail | to json) }
|
|
"yaml" => { print ($detail | to yaml) }
|
|
_ => {
|
|
print $"\n(ansi yellow)($detail.name)(ansi reset) [pending bootstrap — not yet in Hetzner]"
|
|
print ($detail | reject name | table -e -i false)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
let f = ($matches | first)
|
|
let role = ($fip_roles | get -o $f.name | default "—")
|
|
let srv_id = ($f | get -o server | default null)
|
|
let assigned = if $srv_id != null { $srv_map | get -o ($srv_id | into string) | default "—" } else { "—" }
|
|
|
|
let detail = {
|
|
id: ($f.id | into string)
|
|
name: $f.name
|
|
ip: $f.ip
|
|
role: $role
|
|
type: ($f | get -o type | default "ipv4")
|
|
home_location: (extract-location ($f | get -o home_location | default null))
|
|
assigned_to: $assigned
|
|
dns_ptr: (extract-dns-ptr ($f | get -o dns_ptr | default null))
|
|
protection: (fmt-prot ($f | get -o protection | default null))
|
|
labels: ($f | get -o labels | default {})
|
|
state: "created"
|
|
}
|
|
|
|
match ($out | default "") {
|
|
"json" => { print ($detail | to json) }
|
|
"yaml" => { print ($detail | to yaml) }
|
|
_ => {
|
|
print $"\n(ansi cyan_bold)($detail.name)(ansi reset) ($detail.ip)"
|
|
print ($detail | reject name ip | table -e -i false)
|
|
}
|
|
}
|
|
}
|
|
|
|
def _fip-assign [name: string, server: string, --yes (-y)]: nothing -> nothing {
|
|
let fips = (hetzner_api_list_floating_ips)
|
|
let fip_matches = ($fips | where {|f| $f.name == $name })
|
|
if ($fip_matches | is-empty) {
|
|
error make { msg: $"Floating IP '($name)' not found" }
|
|
}
|
|
let fip = ($fip_matches | first)
|
|
let fip_id = ($fip.id | into string)
|
|
|
|
let srv = (do -i { hetzner_api_server_info $server } | default null)
|
|
if $srv == null {
|
|
error make { msg: $"Server '($server)' not found in Hetzner" }
|
|
}
|
|
let srv_id = ($srv.id | into string)
|
|
|
|
let current = ($fip | get -o server | default null)
|
|
if $current != null {
|
|
let current_host = (resolve-server-hostname $current)
|
|
if not $yes {
|
|
print $"FIP ($name) is currently assigned to ($current_host). Reassign to ($server)? [yes/N]"
|
|
let input = (input --numchar 3 | str trim)
|
|
if $input != "yes" { print "Aborted."; return }
|
|
}
|
|
hetzner_api_unassign_floating_ip $fip_id | ignore
|
|
}
|
|
|
|
print $"Assigning ($name) [($fip.ip)] → ($server) [($srv_id)] ..."
|
|
hetzner_api_assign_floating_ip $fip_id $srv_id | ignore
|
|
print $"✓ Assigned"
|
|
}
|
|
|
|
def _fip-unassign [name: string, --yes (-y)]: nothing -> nothing {
|
|
let fips = (hetzner_api_list_floating_ips)
|
|
let matches = ($fips | where {|f| $f.name == $name })
|
|
if ($matches | is-empty) {
|
|
error make { msg: $"Floating IP '($name)' not found" }
|
|
}
|
|
let fip = ($matches | first)
|
|
let fip_id = ($fip.id | into string)
|
|
|
|
let srv_id = ($fip | get -o server | default null)
|
|
if $srv_id == null {
|
|
print $"($name) is not assigned to any server — nothing to do."
|
|
return
|
|
}
|
|
|
|
let hostname = (resolve-server-hostname $srv_id)
|
|
if not $yes {
|
|
print $"Unassign ($name) [($fip.ip)] from ($hostname)? [yes/N]"
|
|
let input = (input --numchar 3 | str trim)
|
|
if $input != "yes" { print "Aborted."; return }
|
|
}
|
|
|
|
print $"Unassigning ($name) from ($hostname) ..."
|
|
hetzner_api_unassign_floating_ip $fip_id | ignore
|
|
print "✓ Unassigned"
|
|
}
|
|
|
|
def _fip-delete [name: string, --yes (-y)]: nothing -> nothing {
|
|
let fips = (hetzner_api_list_floating_ips)
|
|
let matches = ($fips | where {|f| $f.name == $name })
|
|
if ($matches | is-empty) {
|
|
error make { msg: $"Floating IP '($name)' not found" }
|
|
}
|
|
let fip = ($matches | first)
|
|
let fip_id = ($fip.id | into string)
|
|
|
|
let protected = ($fip | get -o protection.delete | default false)
|
|
if $protected {
|
|
error make { msg: $"($name) has delete protection enabled — disable it first with: provisioning fip protection ($name) disable" }
|
|
}
|
|
|
|
let srv_id = ($fip | get -o server | default null)
|
|
if $srv_id != null {
|
|
error make { msg: $"($name) is still assigned to a server — unassign it first with: provisioning fip unassign ($name)" }
|
|
}
|
|
|
|
if not $yes {
|
|
print $"Delete floating IP ($name) [($fip.ip)] permanently? [yes/N]"
|
|
let input = (input --numchar 3 | str trim)
|
|
if $input != "yes" { print "Aborted."; return }
|
|
}
|
|
|
|
print $"Deleting ($name) [($fip.ip)] ..."
|
|
hetzner_api_delete_floating_ip $fip_id | ignore
|
|
print $"✓ Deleted"
|
|
}
|
|
|
|
def _fip-protection [name: string, action: string]: nothing -> nothing {
|
|
let valid = ["enable", "disable"]
|
|
if not ($action in $valid) {
|
|
error make { msg: $"Invalid action '($action)'. Use: enable | disable" }
|
|
}
|
|
|
|
let fips = (hetzner_api_list_floating_ips)
|
|
let matches = ($fips | where {|f| $f.name == $name })
|
|
if ($matches | is-empty) {
|
|
error make { msg: $"Floating IP '($name)' not found" }
|
|
}
|
|
let fip = ($matches | first)
|
|
let fip_id = ($fip.id | into string)
|
|
let enable = ($action == "enable")
|
|
|
|
print $"($action | str capitalize)ing delete protection on ($name) ..."
|
|
hetzner_api_floating_ip_change_protection $fip_id $enable | ignore
|
|
print $"✓ Protection ($action)d"
|
|
}
|
|
|
|
# ── Public subcommands (module API) ───────────────────────────────────────────
|
|
|
|
# List all Floating IPs with role, assigned server, and protection status.
|
|
export def "main list" [
|
|
--out: string # Output format: json | yaml | text (default)
|
|
]: nothing -> nothing {
|
|
_fip-list --out ($out | default "")
|
|
}
|
|
|
|
# Show detailed information about a single Floating IP.
|
|
export def "main show" [
|
|
name: string # FIP name or IP address
|
|
--out: string # Output format: json | yaml | text (default)
|
|
]: nothing -> nothing {
|
|
_fip-show $name --out ($out | default "")
|
|
}
|
|
|
|
# Assign a Floating IP to a server (looked up by hostname).
|
|
export def "main assign" [
|
|
name: string # FIP name
|
|
server: string # Target server hostname
|
|
--yes (-y) # Skip confirmation
|
|
]: nothing -> nothing {
|
|
if $yes { _fip-assign $name $server --yes } else { _fip-assign $name $server }
|
|
}
|
|
|
|
# Unassign a Floating IP from its current server.
|
|
export def "main unassign" [
|
|
name: string # FIP name
|
|
--yes (-y) # Skip confirmation
|
|
]: nothing -> nothing {
|
|
if $yes { _fip-unassign $name --yes } else { _fip-unassign $name }
|
|
}
|
|
|
|
# Delete a Floating IP permanently. FIP must be unassigned and protection-free.
|
|
export def "main delete" [
|
|
name: string # FIP name
|
|
--yes (-y) # Skip confirmation
|
|
]: nothing -> nothing {
|
|
if $yes { _fip-delete $name --yes } else { _fip-delete $name }
|
|
}
|
|
|
|
# Enable or disable delete protection on a Floating IP.
|
|
export def "main protection" [
|
|
name: string # FIP name
|
|
action: string # enable | disable
|
|
]: nothing -> nothing {
|
|
_fip-protection $name $action
|
|
}
|
|
|
|
# ── Script entry point ────────────────────────────────────────────────────────
|
|
# Active only when fip.nu is run directly (nu fip.nu list).
|
|
# Not exported: invisible when fip.nu is `use`d by infrastructure.nu.
|
|
|
|
def main [
|
|
subcommand?: string # list | show | assign | unassign | delete | protection
|
|
...args: string
|
|
--out: string # Output format: json | yaml | text
|
|
--yes (-y) # Skip confirmation prompts
|
|
]: nothing -> nothing {
|
|
let sub = ($subcommand | default "list")
|
|
if $sub == "list" {
|
|
_fip-list --out ($out | default "")
|
|
} else if $sub == "show" {
|
|
_fip-show ($args | first | default "") --out ($out | default "")
|
|
} else if $sub == "assign" {
|
|
let fip = ($args | get -o 0 | default "")
|
|
let srv = ($args | get -o 1 | default "")
|
|
if $yes { _fip-assign $fip $srv --yes } else { _fip-assign $fip $srv }
|
|
} else if $sub == "unassign" {
|
|
let fip = ($args | first | default "")
|
|
if $yes { _fip-unassign $fip --yes } else { _fip-unassign $fip }
|
|
} else if $sub == "delete" {
|
|
let fip = ($args | first | default "")
|
|
if $yes { _fip-delete $fip --yes } else { _fip-delete $fip }
|
|
} else if $sub == "protection" {
|
|
_fip-protection ($args | get -o 0 | default "") ($args | get -o 1 | default "")
|
|
} else {
|
|
print $"Unknown fip subcommand: ($sub)"
|
|
print "Usage: provisioning fip <list|show|assign|unassign|delete|protection> [args]"
|
|
}
|
|
}
|