prvng_core/nulib/servers/delete.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

301 lines
14 KiB
Text
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 <hostname>
# provisioning server delete <hostname> --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 <hostname> [--infra <infra>] [--yes]\n provisioning server delete --all --infra <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
}
}