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