- 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.
301 lines
14 KiB
Text
301 lines
14 KiB
Text
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
|
||
}
|
||
}
|