use ../lib_provisioning/utils/init.nu * use ../lib_provisioning/utils/interface.nu [_ansi _print end_run set-provisioning-out set-provisioning-no-terminal] use ../lib_provisioning/utils/undefined.nu [invalid_task] use ../lib_provisioning/utils/settings.nu * # Sync .servers-state.json from live hcloud data. # Called after create, delete, or update so server list always reflects actual state. export def sync-servers-state-post-op [ws_root: string, infra_name: string] { let state_path = ($ws_root | path join "infra" | path join $infra_name | path join ".servers-state.json") let hcloud_res = (do { ^hcloud server list -o json } | complete) if $hcloud_res.exit_code != 0 or ($hcloud_res.stdout | str trim | is-empty) { print " ⚠ hcloud unavailable — skipping state sync" return } let live = ($hcloud_res.stdout | from json) let fip_res = (do { ^hcloud floating-ip list -o json } | complete) let fip_map = if $fip_res.exit_code == 0 and ($fip_res.stdout | str trim | is-not-empty) { $fip_res.stdout | from json | reduce --fold {} {|fip, acc| let srv_id = ($fip | get -o server | default 0) if $srv_id != 0 { $acc | insert ($srv_id | into string) { name: $fip.name, ip: $fip.ip } } else { $acc } } } else { {} } let state = ($live | reduce --fold {} {|srv, acc| let fip = ($fip_map | get -o ($srv.id | into string) | default null) $acc | insert $srv.name { provider_id: ($srv.id | into string), public_ip: ($srv.public_net?.ipv4?.ip? | default ""), location: ($srv.datacenter?.location?.name? | default ""), status: $srv.status, floating_ip: (if $fip != null { $fip.name } else { "" }), floating_ip_address: (if $fip != null { $fip.ip } else { "" }), protection_delete: ($srv.protection?.delete? | default false), last_sync: (date now | format date "%Y-%m-%dT%H:%M:%SZ"), } }) $state | to json --indent 2 | save --force $state_path print $" ✓ server state synced → ($state_path)" } # Delete orphaned volumes declared in the infra config that exist in Hetzner but are unattached. def delete_orphaned_infra_volumes [settings: record, yes: bool] { let declared_vols = ( $settings.data.servers | each {|s| $s.storage?.additional_volumes? | default []} | flatten | each {|v| $v.name} | uniq ) if ($declared_vols | is-empty) { return } let live_res = (do { ^hcloud volume list -o json } | complete) let live_vols = if $live_res.exit_code == 0 and ($live_res.stdout | str trim | is-not-empty) { $live_res.stdout | from json } else { [] } let orphans = ($live_vols | where {|v| ($declared_vols | any {|n| $n == $v.name}) and ($v.server? | default null) == null }) if ($orphans | is-empty) { return } _print $"\nOrphaned volumes from infra: ($orphans | each {|v| $v.name} | str join ', ')" if not $yes { _print "Delete orphaned volumes? Data will be lost. [y/N] " let ans = (input "") if $ans not-in ["y", "Y", "yes"] { _print "Skipped."; return } } for vol in $orphans { _print $" Deleting orphaned volume ($vol.name)..." if ($vol.protection?.delete? | default false) { do { ^hcloud volume disable-protection $vol.name delete } | complete | ignore } let res = (do { ^hcloud volume delete $vol.name } | complete) if $res.exit_code == 0 { _print $" ✓ ($vol.name) deleted" } else { _print $" ⚠ Failed: ($res.stderr)" } } } # Delete one server or all servers in an infra from Hetzner Cloud. # # Single server: # provisioning server delete # provisioning server delete --yes # # All servers in infra (only those that exist in Hetzner): # provisioning server delete # provisioning server delete --yes # # Volume and FIP handling (interactive prompt unless flag given): # --del-volume Delete attached volumes. Default: detach only, data preserved. # --del-fip Delete the floating IP. Default: unassign only, FIP returns to pool. # # Examples: # prvng server delete libre-daoshi-0 # prvng server delete libre-daoshi-0 --yes --del-volume --del-fip # prvng server delete --yes # delete all, keep volumes + FIPs # prvng server delete --yes --del-volume # delete all + volumes export def "main delete" [ name?: string # Hostname to delete. Omit to delete all servers in infra. --infra (-i): string = "" # Infra name (auto-detected from PWD if omitted) --all (-a) # Explicit flag to confirm all-server delete (optional, same as no name) --yes (-y) # Skip all confirmation prompts --del-volume # Delete attached block volumes (default: preserve, detach only) --del-fip # Delete assigned floating IPs (default: unassign only, back to pool) --debug (-x) --out: string = "" ]: nothing -> nothing { if ($out | is-not-empty) { set-provisioning-out $out set-provisioning-no-terminal true } let server_name = ($name | default "") # --all: intersect declared servers with what actually exists in Hetzner if $all and ($server_name | is-empty) { let settings = (find_get_settings --infra $infra) let declared = ($settings.data.servers | each {|s| $s.hostname}) if ($declared | is-empty) { error make { msg: "No servers declared in infra" } } # Query live Hetzner state — only delete what actually exists let live_res = (do { ^hcloud server list -o json } | complete) let live_names = if $live_res.exit_code == 0 and ($live_res.stdout | str trim | is-not-empty) { $live_res.stdout | from json | each {|s| $s.name} } else { [] } let hostnames = ($declared | where {|h| $live_names | any {|l| $l == $h}}) let missing = ($declared | where {|h| not ($live_names | any {|l| $l == $h})}) for h in $missing { _print $"ℹ️ ($h) not found in Hetzner — skipping" } if ($hostnames | is-empty) { _print "Nothing to delete — no declared servers exist in Hetzner." # Still clean up orphaned infra volumes if --del-volume if $del_volume { delete_orphaned_infra_volumes $settings $yes } return } _print $"Will delete ($hostnames | length) server\(s\): ($hostnames | str join ', ')" if not $yes { _print "Type 'yes' to confirm deletion of ALL servers: " let confirm = (input "") if $confirm != "yes" { _print "Aborted."; return } } for hostname in $hostnames { if $del_volume and $del_fip { main delete $hostname --infra $infra --yes --del-volume --del-fip } else if $del_volume { main delete $hostname --infra $infra --yes --del-volume } else if $del_fip { main delete $hostname --infra $infra --yes --del-fip } else { main delete $hostname --infra $infra --yes } } # Clean up any remaining orphaned volumes declared in infra if $del_volume { delete_orphaned_infra_volumes $settings $yes } return } if ($server_name | is-empty) { error make { msg: "Usage: provisioning server delete [--infra ] [--yes]\n provisioning server delete --all --infra [--yes]" } } let infra_name = if ($infra | is-not-empty) { $infra | path basename } else { "" } let ws_root = ($env.PROVISIONING_WORKSPACE_PATH? | default "") # Fetch server info — skip gracefully if not found let describe_res = (do { ^hcloud server describe $server_name -o json } | complete) if $describe_res.exit_code != 0 { _print $"ℹ️ Server '($server_name)' not found in Hetzner — nothing to delete" return } let srv = ($describe_res.stdout | from json) let srv_id = ($srv.id | into string) let prot = ($srv | get -o protection | default {}) let locked = ($prot.delete? | default false) # Collect attached resources let attached_vols = ( do { ^hcloud volume list -o json } | complete | if $in.exit_code == 0 { $in.stdout | from json | where {|v| ($v.server?.id? | default 0 | into string) == $srv_id} } else { [] } ) let assigned_fips = ( do { ^hcloud floating-ip list -o json } | complete | if $in.exit_code == 0 { $in.stdout | from json | where {|f| ($f.server?.id? | default 0 | into string) == $srv_id} } else { [] } ) # Summary before confirmation _print $"\nServer: ($server_name) \(id: ($srv_id), status: ($srv.status), protection: delete=($locked)\)" if ($attached_vols | is-not-empty) { _print $" Volumes : ($attached_vols | each {|v| $v.name} | str join ', ')" } if ($assigned_fips | is-not-empty) { let fip_list = ($assigned_fips | each {|f| $"($f.name) ($f.ip)"} | str join ', ') _print $" FIPs : ($fip_list)" } # Determine volume/FIP action interactively when not forced mut do_delete_vols = $del_volume mut do_del_fip = $del_fip if not $yes { _print "" if ($attached_vols | is-not-empty) and not $del_volume { _print $"Delete ($attached_vols | length) volume\(s\)? Data will be lost. [y/N] " let ans = (input "") $do_delete_vols = ($ans in ["y", "Y", "yes"]) } if ($assigned_fips | is-not-empty) and not $del_fip { _print $"Delete ($assigned_fips | length) FIP\(s\)? \(N = unassign only, keeps FIP in pool\) [y/N] " let ans = (input "") $do_del_fip = ($ans in ["y", "Y", "yes"]) } _print $"\nType '($server_name)' to confirm permanent deletion: " let confirm = (input "") if $confirm != $server_name { _print "Aborted."; return } } # Step 1: Disable protection if $locked { _print $" Disabling protection on ($server_name)..." let res = (do { ^hcloud server disable-protection $server_name delete rebuild } | complete) if $res.exit_code != 0 { error make { msg: $"Failed to disable protection: ($res.stderr)" } } _print " ✓ protection disabled" } # Step 2: Handle FIPs before server deletion for fip in $assigned_fips { if $do_del_fip { _print $" Deleting FIP ($fip.name)..." # Disable FIP protection if set if ($fip.protection?.delete? | default false) { do { ^hcloud floating-ip disable-protection $fip.name delete } | complete | ignore } let res = (do { ^hcloud floating-ip delete $fip.name } | complete) if $res.exit_code == 0 { _print $" ✓ FIP ($fip.name) deleted" } else { _print $" ⚠ Failed to delete FIP ($fip.name): ($res.stderr)" } } else { _print $" Unassigning FIP ($fip.name)..." let res = (do { ^hcloud floating-ip unassign $fip.name } | complete) if $res.exit_code == 0 { _print $" ✓ FIP ($fip.name) unassigned → back to pool" } else { _print $" ⚠ Failed to unassign FIP ($fip.name): ($res.stderr)" } } } # Step 3: Delete server _print $" Deleting ($server_name)..." let del_res = (do { ^hcloud server delete $server_name } | complete) if $del_res.exit_code != 0 { error make { msg: $"Failed to delete server: ($del_res.stderr)" } } _print $" ✓ ($server_name) deleted" # Step 4: Handle volumes after server deletion (auto-detached on server delete) for vol in $attached_vols { if $do_delete_vols { _print $" Deleting volume ($vol.name)..." if ($vol.protection?.delete? | default false) { do { ^hcloud volume disable-protection $vol.name delete } | complete | ignore } let res = (do { ^hcloud volume delete $vol.name } | complete) if $res.exit_code == 0 { _print $" ✓ volume ($vol.name) deleted" } else { _print $" ⚠ Failed to delete volume ($vol.name): ($res.stderr)" } } else { _print $" Volume ($vol.name) preserved (detached)" } } # Step 3: Sync state — resolve ws_root from user_config.yaml if env var not propagated mut sync_ws = $ws_root if ($sync_ws | is-empty) { let user_config_path = ($env.HOME | path join "Library" "Application Support" "provisioning" "user_config.yaml") if ($user_config_path | path exists) { let config = (open $user_config_path) let active_name = ($config | get -o active_workspace | default "") let ws = ($config | get -o workspaces | default [] | where { $in.name == $active_name } | first | default null) if $ws != null { $sync_ws = $ws.path } } } let sync_infra = if ($infra_name | is-not-empty) { $infra_name } else { let user_config_path = ($env.HOME | path join "Library" "Application Support" "provisioning" "user_config.yaml") if ($user_config_path | path exists) { let config = (open $user_config_path) let active_name = ($config | get -o active_workspace | default "") $config | get -o workspaces | default [] | where { $in.name == $active_name } | first | default {} | get -o default_infra | default "" } else { "" } } if ($sync_ws | is-not-empty) and ($sync_infra | is-not-empty) { _print "\n[state sync]" sync-servers-state-post-op $sync_ws $sync_infra } }