#!/usr/bin/env nu # prvng provider — live resource state for catalog providers. 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 { [] })) } def prov-root [] { let prov = ($env.PROVISIONING? | default "") if ($prov | is-empty) { error make --unspanned { msg: "PROVISIONING env var not set" } } let direct = ($prov | path join "catalog" "providers") if ($direct | path exists) { return $direct } let via_code = ($prov | path join "code" "catalog" "providers") if ($via_code | path exists) { return $via_code } error make --unspanned { msg: $"catalog/providers not found under ($prov)" } } def available-providers [] { ls (prov-root) | where type == "dir" | get name | each { |p| $p | path basename } } def hcloud-all [] { let r = (do { ^hcloud all list -o json } | complete) if $r.exit_code != 0 { error make --unspanned { msg: $"hcloud failed: ($r.stderr | str trim)" } } $r.stdout | from json } def server-location-price [server] { let loc = $server.location.name let prices = ($server.server_type.prices? | default []) let matched = $prices | where location == $loc let entry = if ($matched | is-empty) { if ($prices | is-empty) { return 0.0 } else { $prices | first } } else { $matched | first } $entry.price_monthly.net | into float } def sep-line [char: string, width: int] { 0..<$width | each { $char } | str join "" } def fmt-eur [amount: float] { let cents = ($amount * 100 | math round | into int) let euros = ($cents // 100) let c = ($cents mod 100 | into string | fill -a r -w 2 -c "0") $"($euros).($c)" } def plural [n: int, singular: string, plural_form: string] { if $n == 1 { $"($n) ($singular)" } else { $"($n) ($plural_form)" } } # Collect all Hetzner resource data into a clean record (used by both text and JSON output). def hetzner-collect [] { let r = (hcloud-all) let servers = ($r.servers? | default []) let volumes = ($r.volumes? | default []) let fips = ($r.floating_ips? | default []) let primary_ips = ($r.primary_ips? | default []) let firewalls = ($r.firewalls? | default []) let networks = ($r.networks? | default []) let pgroups = ($r.placement_groups? | default []) let ssh_keys = ($r.ssh_keys? | default []) let primary_ipv4 = ($primary_ips | where type == "ipv4") # Rates: Hetzner invoice 05/2026 (net, excl. VAT) let server_total = if ($servers | is-empty) { 0.0 } else { $servers | each { |s| server-location-price $s } | math sum } let volume_gb = if ($volumes | is-empty) { 0 } else { $volumes | each { |v| $v.size } | math sum } let volume_cost = ($volume_gb * 0.0572) let fip_cost = (($fips | length) * 3.00) let primary_cost = (($primary_ipv4 | length) * 0.50) let obj_cost = 6.49 let total_net = ($server_total + $volume_cost + $fip_cost + $primary_cost + $obj_cost) { provider: "hetzner" resources: { servers: ($servers | each { |s| { name: $s.name status: $s.status type: $s.server_type.name location: $s.location.name monthly_eur: (server-location-price $s) } }) volumes: ($volumes | each { |v| { name: $v.name size_gb: $v.size location: ($v.location? | default {} | get -o name | default "?") status: $v.status } }) floating_ips: ($fips | each { |f| let labels = ($f.labels? | default {}) { name: $f.name ip: $f.ip type: $f.type workspace: ($labels | get -o workspace | default ($labels | get -o cluster | default "")) } }) primary_ipv4: ($primary_ipv4 | each { |p| { name: $p.name ip: $p.ip assigned: ($p.assignee_id? != null) } }) firewalls: ($firewalls | each { |fw| { name: $fw.name applied_to: ($fw.applied_to? | default [] | length) } }) networks: ($networks | each { |n| { name: $n.name ip_range: ($n.ip_range? | default "?") server_count: ($n.servers? | default [] | length) } }) placement_groups: ($pgroups | each { |pg| { name: $pg.name type: ($pg.type? | default "") server_count: ($pg.servers? | default [] | length) } }) ssh_keys: ($ssh_keys | each { |k| { name: $k.name } }) } costs: { servers: ($server_total | math round -p 2) volumes: ($volume_cost | math round -p 2) volumes_gb: $volume_gb floating_ips: ($fip_cost | math round -p 2) primary_ips: ($primary_cost | math round -p 2) object_storage: $obj_cost total_net: ($total_net | math round -p 2) total_vat: (($total_net * 1.19) | math round -p 2) } } } def hetzner-usage [fmt: string] { let d = (hetzner-collect) match $fmt { "json" => { $d | to json | print; return } "yaml" => { $d | to yaml | print; return } "text" | "human" => {} _ => { error make --unspanned { msg: $"Unknown format '($fmt)'. Use: text, json, yaml" } } } let s = $d.resources.servers let v = $d.resources.volumes let f = $d.resources.floating_ips let pi = $d.resources.primary_ipv4 let fw = $d.resources.firewalls let n = $d.resources.networks let pg = $d.resources.placement_groups let sk = $d.resources.ssh_keys let c = $d.costs print $"Hetzner Cloud — Active Resources" print (sep-line "═" 52) print "" if ($s | is-not-empty) { print $"servers \(($s | length)\)" for srv in $s { let name_col = ($srv.name | fill -a l -w 26) let status_col = ($srv.status | fill -a l -w 9) let type_col = ($srv.type | fill -a l -w 7) let loc_col = ($srv.location | fill -a l -w 5) print $" ($name_col) ($status_col) ($type_col) ($loc_col) €(fmt-eur $srv.monthly_eur)/mo" } print "" } if ($v | is-not-empty) { print $"volumes \(($v | length)\)" for vol in $v { let name_col = ($vol.name | fill -a l -w 38) let size_col = ($"($vol.size_gb)GB" | fill -a l -w 7) print $" ($name_col) ($size_col) ($vol.location) ($vol.status)" } print "" } if ($f | is-not-empty) { print $"floating_ips \(($f | length)\)" for fip in $f { let name_col = ($fip.name | fill -a l -w 30) let ip_col = ($fip.ip | fill -a l -w 16) print $" ($name_col) ($ip_col) ($fip.type) ($fip.workspace)" } print "" } if ($pi | is-not-empty) { print $"primary_ips IPv4 \(($pi | length)\)" for p in $pi { let name_col = ($p.name | fill -a l -w 30) let ip_col = ($p.ip | fill -a l -w 16) let asgn = if $p.assigned { "assigned" } else { "unassigned" } print $" ($name_col) ($ip_col) ($asgn)" } print "" } if ($fw | is-not-empty) { print $"firewalls \(($fw | length)\)" for firewall in $fw { let name_col = ($firewall.name | fill -a l -w 30) print $" ($name_col) applied to (plural $firewall.applied_to "resource" "resources")" } print "" } if ($n | is-not-empty) { print $"networks \(($n | length)\)" for net in $n { let name_col = ($net.name | fill -a l -w 22) print $" ($name_col) ($net.ip_range) ($net.server_count) servers" } print "" } if ($pg | is-not-empty) { print $"placement_groups \(($pg | length)\)" for g in $pg { let name_col = ($g.name | fill -a l -w 26) print $" ($name_col) type: ($g.type) ($g.server_count) servers" } print "" } if ($sk | is-not-empty) { print $"ssh_keys \(($sk | length)\)" for k in $sk { print $" ($k.name)" } print "" } print (sep-line "─" 52) print "Monthly estimate (net):" print $" servers €(fmt-eur $c.servers)" print $" volumes €(fmt-eur $c.volumes) \(($c.volumes_gb)GB × €0.0572/GB\)" print $" floating IPv4 €(fmt-eur $c.floating_ips) \(($f | length) × €3.00\)" print $" primary IPv4 €(fmt-eur $c.primary_ips) \(($pi | length) × €0.50\)" print $" object storage €(fmt-eur $c.object_storage) \(base — S3, not in hcloud\)" print $" ─────────────────────────────────" print $" total net €(fmt-eur $c.total_net)/mo" print $" total +VAT €(fmt-eur $c.total_vat)/mo" } def cmd-usage [provider: string, fmt: string] { match $provider { "hetzner" => { hetzner-usage $fmt } _ => { let available = (try { available-providers | str join ", " } catch { "unknown" }) error make --unspanned { msg: $"No usage handler for '($provider)'. Catalog providers: ($available)" } } } } def main [ ...args: string --fmt: string = "text" # output format: text (default), json, yaml --json (-j) # shorthand for --fmt json ] { let format = if $json { "json" } else { $fmt } let cmd = $args | get 0? | default "" let provider = $args | get 1? | default "" let sub = $args | get 2? | default "usage" match $cmd { "provider" | "pvd" => { if ($provider | is-empty) { print "prvng provider [usage] [--fmt text|json|yaml]" print "" print "Available providers:" for p in (try { available-providers } catch { [] }) { print $" ($p)" } return } match $sub { "usage" | "u" => { cmd-usage $provider $format } _ => { print $"prvng provider ($provider) [usage] [--fmt text|json|yaml]" print "" print "Subcommands:" print " usage Live resources + monthly cost estimate" } } } _ => { print "Usage: prvng provider [usage] [--fmt text|json|yaml]" } } }