2026-07-09 21:57:14 +01:00
|
|
|
|
#!/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" }
|
|
|
|
|
|
}
|
2026-07-10 07:16:14 +01:00
|
|
|
|
# catalog/ now lives at the provisioning root (moved out of the old code/ sub-repo).
|
2026-07-09 21:57:14 +01:00
|
|
|
|
let direct = ($prov | path join "catalog" "providers")
|
|
|
|
|
|
if ($direct | path exists) { return $direct }
|
|
|
|
|
|
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 <name> [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 <name> [usage] [--fmt text|json|yaml]"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|