263 lines
12 KiB
Text
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
|
|
}
|
|
}
|
|
}
|