provisioning-core/nulib/cli/storage_box.nu

263 lines
12 KiB
Text

#!/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/<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 <name> # 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 <ws>/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 <name> (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_<password_env_key> 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_<password_env_key>.
# 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/<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/<infra>/secrets/storage-box.sops.yaml:"
print " STORAGEBOX_PASSWORD: <primary account password>"
print " STORAGEBOX_SUB_PASSWORD_<password_env_key>: <that subaccount's 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 <name>"
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 <name>" } }
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
}
}
}