Untangle the shared foundation that the cluster subsystem depends on, moving each helper to the layer its dependency topology dictates, de-cli-ifying on the way (cli-err → error make). One-directional imports only; verified no cycles. comp-ncl-export → tools/nickel/process.nu ws-resolution (8 fns + root finders) → domain/workspace/resolve.nu (new) comp-ssh-run, comp-server-ssh-info → platform/clients/ssh.nu (new) comp-kubectl-access, comp-kubectl-ssh → domain/cluster/access.nu (new) Consumers (components.nu, components_cluster.nu, component.nu) repointed to the new locations. components.nu 2664 → 2408. Verified: NU_LIB_DIRS module-load of all 8 touched modules (no cycle, no missing command, no export error); ADR-026 hygiene (no upward imports in the new platform/domain/tools modules); `provisioning component list` runs; component/cluster --help exit 0. Prereq for slice 2b (cluster helpers → domain/cluster/introspect.nu).
319 lines
11 KiB
Text
319 lines
11 KiB
Text
#!/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" }
|
||
}
|
||
# catalog/ now lives at the provisioning root (moved out of the old code/ sub-repo).
|
||
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]"
|
||
}
|
||
}
|
||
}
|