prvng_core/nulib/main_provisioning/fip.nu
Jesús Pérez 894046ef5a
feat(core): three-layer DAG, unified component arch, commands-registry cache, Nushell 0.112.2 migration
- 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.
2026-04-17 04:27:33 +01:00

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]"
}
}