#!/usr/bin/env nu # Native entry for the `storage-box` (alias `store-box`) command — Hetzner Storage # Box lifecycle as a first-class storage resource, declared alongside servers # (sibling `storage_boxes` list in infra//servers.ncl) and managed with # its own verbs, separate from `server`. # # provisioning storage-box [check] # show the declared NCL config (read-only) # provisioning storage-box check --script # also show the rendered create script # provisioning storage-box create # render + execute (needs SOPS creds + hcloud) # provisioning storage-box list # live Hetzner truth # provisioning storage-box status [name] # describe declared boxes # provisioning storage-box delete # delete (respects delete protection) # provisioning storage-box howto # usage guide # # `check` shows what servers.ncl declares, not a Terraform-style diff/plan — # same posture as `server ... --check`. Rendered via bat when available. # # See: catalog/providers/hetzner (StorageBox contract + hetzner_storage_box.j2) export-env { let lib_dirs_raw = ($env.NU_LIB_DIRS? | default "") let current_lib_dirs = if ($lib_dirs_raw | type) == "string" { if ($lib_dirs_raw | is-empty) { [] } else { ($lib_dirs_raw | split row ":") } } else { $lib_dirs_raw } let dynamic = ($env.PROVISIONING? | default "" | path join "core" "nulib") $env.NU_LIB_DIRS = ([ "/opt/provisioning/core/nulib" "/usr/local/provisioning/core/nulib" ] | append $current_lib_dirs | append (if ($dynamic | is-not-empty) { [$dynamic] } else { [] })) } # Directory that contains catalog/ (provisioning's code root). def prov []: nothing -> string { $env.PROVISIONING? | default "" } def template_path []: nothing -> string { (prov) | path join "catalog/providers/hetzner/templates/hetzner_storage_box.j2" } # Resolve the infra directory name under /infra (single one auto-detected). def resolve_infra [ws: string, infra: string]: nothing -> string { if ($infra | is-not-empty) { return $infra } let base = ($ws | path join "infra") let dirs = (try { ls $base | where type == dir | get name | path basename | where {|d| $d != "lib"} } catch { [] }) if (($dirs | length) == 1) { ($dirs | first) } else { error make --unspanned { msg: $"could not auto-detect infra under ($base) — pass --infra (found: ($dirs | str join ', '))" } } } # Project the sibling `storage_boxes` list from the infra declaration. def load_boxes [ws: string, infra: string]: nothing -> list { let rel = ($"infra/($infra)/servers.ncl") let ncl = ($ws | path join $rel) if not ($ncl | path exists) { error make --unspanned { msg: $"not found: ($ncl)" } } let expr = ("(import \"" + $rel + "\").storage_boxes") let r = (do { cd $ws; $expr | ^nickel export --format json --import-path (prov) } | complete) if $r.exit_code != 0 { if (($r.stderr | str downcase) =~ "missing field|field.*not found|storage_boxes") { return [] } error make --unspanned { msg: $"nickel export failed:\n($r.stderr | str trim)" } } $r.stdout | from json } def load_creds [ws: string, infra: string]: nothing -> record { let sops = ($ws | path join $"infra/($infra)/secrets/storage-box.sops.yaml") let kage = ($ws | path join $"infra/($infra)/.kage") if not ($sops | path exists) { error make --unspanned { msg: $"creds file not found: ($sops) — needs STORAGEBOX_PASSWORD \(and one STORAGEBOX_SUB_PASSWORD_ per declared subaccount)." } } let r = (with-env { SOPS_AGE_KEY_FILE: $kage } { do { ^sops --decrypt $sops } | complete }) if $r.exit_code != 0 { error make --unspanned { msg: $"sops decrypt failed: ($r.stderr | str trim)" } } $r.stdout | from yaml } # One env var per (box, subaccount) — STORAGEBOX_SUB_PASSWORD_. # Distinct credentials per subaccount: a leaked SMB password must not also # unlock the restic SFTP path on the same box. def subaccount_env_map [boxes: list, creds: record]: nothing -> record { $boxes | each {|b| $b.subaccounts? | default [] } | flatten | each {|sub| let key = ($sub | get -o password_env_key | default "") if ($key | is-empty) { error make --unspanned { msg: $"subaccount {home_directory=($sub.home_directory)} is missing required field 'password_env_key'" } } let var = $"STORAGEBOX_SUB_PASSWORD_($key)" { ($var): ($creds | get -o $var | default "") } } | reduce --fold {} {|it, acc| $acc | merge $it } } # Render the provider template against the declared boxes; returns the bash script. def render [boxes: list]: nothing -> string { let tpl = (template_path) if not ($tpl | path exists) { error make --unspanned { msg: $"template not found: ($tpl) — is PROVISIONING set to the code root?" } } let tmp = (mktemp --suffix ".json") { storage_boxes: $boxes } | to json | save -f $tmp plugin use tera let out = (tera-render $tpl $tmp) rm -f $tmp $out } def howto [] { print "Hetzner Storage Box — declarative lifecycle (provisioning storage-box / store-box)" print "" print "1. Declare alongside servers in infra//servers.ncl (sibling of `servers`):" print " let hz = import \"catalog/providers/hetzner/nickel/main.ncl\" in" print " { servers = [ ... ]," print " storage_boxes = [" print " hz.make_storage_box {" print " name = \"libre-wuji-backup-box\", type = \"bx11\", location = \"fsn1\"," print " ssh_keys = [\"jpl-backup\"]," print " subaccounts = [{ home_directory = \"/libre-wuji-backups\", password_env_key = \"RESTIC\", description = \"restic\" }]," print " protection = { delete = true } } ] }" print "" print " password_env_key is mandatory per subaccount — it names the SOPS key" print " that holds THAT subaccount's password. Each subaccount gets its own" print " credential; none of them share a password with another subaccount." print "" print "2. Secrets in infra//secrets/storage-box.sops.yaml:" print " STORAGEBOX_PASSWORD: " print " STORAGEBOX_SUB_PASSWORD_: # one per subaccount" print "" print "3. Run (where hcloud is authenticated, HCLOUD_TOKEN set):" print " provisioning storage-box # check — show declared config (read-only)" print " provisioning storage-box check --script # also show the rendered create script" print " provisioning storage-box create # create/reconcile (idempotent)" print " provisioning storage-box list # live Hetzner truth" print " provisioning storage-box status # describe declared boxes" print " provisioning storage-box delete " print "" print "Protocol toggles (enable_ssh/enable_samba/enable_webdav/reachable_externally)" print "reconcile on every `create` run, even against an already-existing box or" print "subaccount — Hetzner applies them live via update-access-settings, no reboot." print "" print "Types: bx11=1TiB bx21=5TiB bx31=10TiB bx41=20TiB. Access: SFTP (port 23)/Samba/WebDAV/Borg." print "Use as a restic destination: declare it in infra/lib/backup_policies.ncl (kind='sftp)." } def file_viewer []: nothing -> string { if (which bat | is-not-empty) { "bat" } else { "cat" } } def show_yaml [data: any, title: string] { let cmd = (file_viewer) print $"── ($title) ──────────────────────────────────────────────" let text = ($data | to yaml) if $cmd == "bat" { ($text | ^bat --language yaml --style plain --paging never -) } else { print $text } } def show_bash [script: string, title: string] { let cmd = (file_viewer) print $"── ($title) ──────────────────────────────────────────────" if $cmd == "bat" { ($script | ^bat --language bash --style plain --paging never -) } else { print $script } } # Shows what servers.ncl declares — not a Terraform-style plan/diff. def do_check [ws: string, infra: string, show_script: bool] { let boxes = (load_boxes $ws $infra) if ($boxes | is-empty) { print $"No storage_boxes declared in infra/($infra)/servers.ncl. See `provisioning storage-box howto`." return } print $"Declared storage boxes \(infra=($infra)): (($boxes | get name | str join ', '))" print "" show_yaml $boxes "declared config (servers.ncl :: storage_boxes)" if $show_script { print "" show_bash (render $boxes) "rendered create script (read-only)" } } def do_create [ws: string, infra: string] { let boxes = (load_boxes $ws $infra) if ($boxes | is-empty) { error make --unspanned { msg: $"nothing to create — no storage_boxes in infra/($infra)/servers.ncl" } } let creds = (load_creds $ws $infra) let token = ($env.HCLOUD_TOKEN? | default "") if ($token | is-empty) { error make --unspanned { msg: "HCLOUD_TOKEN not set — run where hcloud is authenticated" } } let script = (render $boxes) let state = ($ws | path join $"infra/($infra)/.storage-box-state") mkdir $state let env_map = { HCLOUD_TOKEN: $token, STATE_DIR: $state, STORAGEBOX_PASSWORD: ($creds | get -o STORAGEBOX_PASSWORD | default ""), } | merge (subaccount_env_map $boxes $creds) print $"→ creating/reconciling (($boxes | length)) storage box\(es)…" let r = (with-env $env_map { do { $script | ^bash } | complete }) print ($r.stdout | str trim) if $r.exit_code != 0 { error make --unspanned { msg: $"create failed:\n($r.stderr | str trim)" } } } def do_list [] { let r = (do { ^hcloud storage-box list -o json } | complete) if $r.exit_code != 0 { error make --unspanned { msg: $"hcloud storage-box list failed — run where hcloud is authenticated: ($r.stderr | str trim)" } } $r.stdout | from json | each {|b| { name: ($b.name? | default "?"), type: ($b.storage_box_type?.name? | default "?"), location: ($b.location?.name? | default ($b.location? | default "?")), status: ($b.status? | default "?"), } } } def do_status [ws: string, infra: string, name: string] { let names = if ($name | is-not-empty) { [$name] } else { (load_boxes $ws $infra | get name) } $names | each {|n| let r = (do { ^hcloud storage-box describe $n -o json } | complete) if $r.exit_code != 0 { { name: $n, status: "NOT FOUND" } } else { let d = ($r.stdout | from json) { name: $n, status: ($d.status? | default "?"), type: ($d.storage_box_type?.name? | default "?"), location: ($d.location?.name? | default "?") } } } } def do_delete [name: string] { if ($name | is-empty) { error make --unspanned { msg: "delete requires a name: provisioning storage-box delete " } } let r = (do { ^hcloud storage-box delete $name } | complete) if $r.exit_code != 0 { error make --unspanned { msg: $"delete failed \(delete protection enabled?\): ($r.stderr | str trim)" } } print $"✓ storage box '($name)' deleted" } def main [ ...args: string --infra (-i): string = "" --workspace (-w): string = "" --script # with check: also show the rendered create script --debug (-x) ]: nothing -> nothing { if $debug { $env.PROVISIONING_DEBUG = true } let ws = if ($workspace | is-not-empty) { $workspace } else { ($env.PROVISIONING_WORKSPACE_PATH? | default $env.PWD) } let rest = if (($args | length) > 0) and (($args | first) in ["storage-box", "store-box", "sbox"]) { $args | skip 1 } else { $args } let sub = if (($rest | length) > 0) { $rest | first } else { "check" } let pos = if (($rest | length) > 1) { $rest | skip 1 | first } else { "" } match $sub { "check" | "c" | "plan" | "p" => { do_check $ws (resolve_infra $ws $infra) $script } "create" | "apply" => { do_create $ws (resolve_infra $ws $infra) } "list" | "ls" | "l" => { do_list } "status" | "st" => { do_status $ws (resolve_infra $ws $infra) $pos } "delete" | "del" | "d" => { do_delete $pos } "howto" | "help" | "h" => { howto } _ => { print $"Unknown subcommand: ($sub)" howto } } }