556 lines
22 KiB
Text
556 lines
22 KiB
Text
|
|
# Hetzner Cloud server operations
|
||
|
|
use env.nu *
|
||
|
|
use api.nu *
|
||
|
|
use ../../../prov_lib/provider_interface.nu *
|
||
|
|
use ../../../prov_lib/provider_common.nu *
|
||
|
|
use ../../../prov_lib/provider_state.nu *
|
||
|
|
use ../../../prov_lib/provider_error.nu *
|
||
|
|
|
||
|
|
# Query/list all servers
|
||
|
|
export def hetzner_query_servers [find: string = "", cols: string = ""]: nothing -> list {
|
||
|
|
if (hetzner_use_api) {
|
||
|
|
let servers = (hetzner_api_list_servers)
|
||
|
|
|
||
|
|
let filtered = if ($find | is-empty) {
|
||
|
|
$servers
|
||
|
|
} else {
|
||
|
|
$servers | where {|s| ($s.name | str downcase) =~ ($find | str downcase) or ($s.public_net.ipv4.ip | str downcase) =~ ($find | str downcase)}
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($cols | is-empty) {
|
||
|
|
$filtered
|
||
|
|
} else {
|
||
|
|
$filtered | select ($cols | split row "," | each {|c| ($c | str trim)})
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
let cli_result = (do { ^hcloud server list -o json } | complete)
|
||
|
|
|
||
|
|
if $cli_result.exit_code != 0 {
|
||
|
|
throw-error $"Failed to list servers: ($cli_result.stderr)"
|
||
|
|
}
|
||
|
|
|
||
|
|
let parsed = ($cli_result.stdout | from json)
|
||
|
|
# hcloud server list -o json returns a JSON array directly, not {servers: [...]}
|
||
|
|
let servers = if ($parsed | describe | str starts-with "list") { $parsed } else if ("servers" in $parsed) { $parsed.servers } else { [] }
|
||
|
|
|
||
|
|
let filtered = if ($find | is-empty) {
|
||
|
|
$servers
|
||
|
|
} else {
|
||
|
|
$servers | where {|s| ($s.name | str downcase) =~ ($find | str downcase)}
|
||
|
|
}
|
||
|
|
|
||
|
|
$filtered
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Get server info by hostname/ID
|
||
|
|
export def hetzner_server_info [hostname: string, check: bool = false, find: string = "", cols: string = ""]: nothing -> record {
|
||
|
|
let server = if (hetzner_use_api) {
|
||
|
|
hetzner_api_server_info $hostname
|
||
|
|
} else {
|
||
|
|
let cli_result = (do { ^hcloud server describe $hostname -o json } | complete)
|
||
|
|
|
||
|
|
if $cli_result.exit_code != 0 {
|
||
|
|
throw-error $"Failed to describe server ($hostname): ($cli_result.stderr)"
|
||
|
|
}
|
||
|
|
|
||
|
|
let parsed = ($cli_result.stdout | from json)
|
||
|
|
if ("server" in $parsed) { $parsed.server } else { $parsed }
|
||
|
|
}
|
||
|
|
|
||
|
|
if $check {
|
||
|
|
if (($server | describe) == "record") {
|
||
|
|
print $"Server found: ($server.name) (($server.status))"
|
||
|
|
} else {
|
||
|
|
print $"Server found: $server"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
$server
|
||
|
|
}
|
||
|
|
|
||
|
|
# Get server ID, status and IPv4 via hcloud CLI in a single call
|
||
|
|
# Returns {} if server not found or hcloud not available
|
||
|
|
# REVIEW(TASK-7): Replace with HTTP GET /api/v1/servers/{hostname} via orchestrator
|
||
|
|
# See plan section TASK 7 — server queries must route through orchestrator, not directly to provider CLI
|
||
|
|
def hcloud_server_info [hostname: string]: nothing -> record {
|
||
|
|
let res = (^hcloud server describe $hostname -o $"format={{.ID}} {{.Status}} {{.PublicNet.IPv4.IP}}" | complete)
|
||
|
|
if $res.exit_code != 0 { return {} }
|
||
|
|
let parts = ($res.stdout | str trim | split column " " id status ipv4)
|
||
|
|
if ($parts | is-empty) { return {} }
|
||
|
|
$parts | first
|
||
|
|
}
|
||
|
|
|
||
|
|
# Check if server exists
|
||
|
|
# REVIEW(TASK-7): hcloud_server_info must route via HTTP GET /api/v1/servers/{hostname} at orchestrator
|
||
|
|
export def hetzner_server_exists [hostname: string, error_exit: bool = false]: nothing -> bool {
|
||
|
|
let info = (hcloud_server_info $hostname)
|
||
|
|
if ($info | is-empty) {
|
||
|
|
if $error_exit { throw-error $"Server does not exist: ($hostname)" }
|
||
|
|
false
|
||
|
|
} else {
|
||
|
|
true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Check if server is running
|
||
|
|
# REVIEW(TASK-7): hcloud_server_info must route via HTTP GET /api/v1/servers/{hostname}/status at orchestrator
|
||
|
|
export def hetzner_server_is_running [hostname: string, error_exit: bool = false]: nothing -> bool {
|
||
|
|
let info = (hcloud_server_info $hostname)
|
||
|
|
if ($info | is-empty) {
|
||
|
|
if $error_exit { throw-error $"Server not found: ($hostname)" }
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
let is_running = ($info.status == "running")
|
||
|
|
if (not $is_running) and $error_exit {
|
||
|
|
throw-error $"Server is not running: ($hostname) (status: ($info.status))"
|
||
|
|
}
|
||
|
|
$is_running
|
||
|
|
}
|
||
|
|
|
||
|
|
# Check server requirements before creation
|
||
|
|
export def hetzner_check_server_requirements [settings: record, server: record, check: bool = false]: nothing -> bool {
|
||
|
|
let required_fields = ["hostname", "server_type", "location", "image"]
|
||
|
|
let missing_fields = $required_fields | where {|field| not ($field in $server) or ($server | get $field | is-empty)}
|
||
|
|
|
||
|
|
if (not ($missing_fields | is-empty)) {
|
||
|
|
throw-error $"Missing required fields: ($missing_fields | str join ', ')"
|
||
|
|
}
|
||
|
|
|
||
|
|
# Validate server type exists
|
||
|
|
let server_types = (hetzner_api_list_server_types)
|
||
|
|
let type_names = $server_types | each {|t| $t.name}
|
||
|
|
|
||
|
|
if not ($server.server_type in $type_names) {
|
||
|
|
throw-error $"Invalid server type: ($server.server_type). Available: ($type_names | str join ', ')"
|
||
|
|
}
|
||
|
|
|
||
|
|
# Validate location exists
|
||
|
|
let locations = (hetzner_api_list_locations)
|
||
|
|
let location_names = $locations | each {|l| $l.network_zone}
|
||
|
|
|
||
|
|
if not ($server.location in $location_names) {
|
||
|
|
throw-error $"Invalid location: ($server.location). Available: ($location_names | str join ', ')"
|
||
|
|
}
|
||
|
|
|
||
|
|
if $check {
|
||
|
|
print "✓ Server requirements validated"
|
||
|
|
}
|
||
|
|
|
||
|
|
true
|
||
|
|
}
|
||
|
|
|
||
|
|
# Create server
|
||
|
|
export def hetzner_create_server [settings: record, server: record, check: bool = false, wait: bool = true]: nothing -> record {
|
||
|
|
if $check {
|
||
|
|
print "Check mode: Would create server"
|
||
|
|
print $" Name: ($server.hostname)"
|
||
|
|
print $" Type: ($server.server_type)"
|
||
|
|
print $" Location: ($server.location)"
|
||
|
|
print $" Image: ($server.image)"
|
||
|
|
return $server
|
||
|
|
}
|
||
|
|
|
||
|
|
hetzner_check_server_requirements $settings $server true
|
||
|
|
|
||
|
|
# Build API payload
|
||
|
|
let ipv4_enabled = ($server | get -o networking.ipv4_enabled | default true)
|
||
|
|
let ipv6_enabled = ($server | get -o networking.ipv6_enabled | default false)
|
||
|
|
let payload = {
|
||
|
|
name: $server.hostname
|
||
|
|
server_type: $server.server_type
|
||
|
|
image: $server.image
|
||
|
|
location: $server.location
|
||
|
|
start_after_create: true
|
||
|
|
automount: ($server | get -o automount | default false)
|
||
|
|
labels: ($server | get -o labels | default {})
|
||
|
|
public_net: { enable_ipv4: $ipv4_enabled, enable_ipv6: $ipv6_enabled }
|
||
|
|
}
|
||
|
|
|
||
|
|
# Add SSH keys if specified
|
||
|
|
let payload = if ("ssh_keys" in $server) and (not ($server.ssh_keys | is-empty)) {
|
||
|
|
$payload | insert ssh_keys ($server.ssh_keys | flatten)
|
||
|
|
} else {
|
||
|
|
$payload
|
||
|
|
}
|
||
|
|
|
||
|
|
# Add user data if specified
|
||
|
|
let payload = if ("user_data" in $server) and (not ($server.user_data | is-empty)) {
|
||
|
|
$payload | insert user_data $server.user_data
|
||
|
|
} else {
|
||
|
|
$payload
|
||
|
|
}
|
||
|
|
|
||
|
|
print $"Creating server: ($server.hostname)..."
|
||
|
|
|
||
|
|
let created_server = if (hetzner_use_api) {
|
||
|
|
hetzner_api_create_server $payload
|
||
|
|
} else {
|
||
|
|
let cmd = $"hcloud server create --name ($server.hostname) --type ($server.server_type) --image ($server.image) --location ($server.location) -o json"
|
||
|
|
let output = (^$cmd | from json)
|
||
|
|
if ("server" in $output) {
|
||
|
|
$output.server
|
||
|
|
} else {
|
||
|
|
$output
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
let delete_protected = ($server | get -o protection.delete | default false)
|
||
|
|
let rebuild_protected = ($server | get -o protection.rebuild | default false)
|
||
|
|
if $delete_protected or $rebuild_protected {
|
||
|
|
print $" enabling protection \(delete=($delete_protected) rebuild=($rebuild_protected)\) on ($server.hostname) ..."
|
||
|
|
let _action = (hetzner_api_server_change_protection ($created_server.id | into string) $delete_protected $rebuild_protected)
|
||
|
|
}
|
||
|
|
|
||
|
|
if $wait {
|
||
|
|
print "Waiting for server to be ready..."
|
||
|
|
sleep 5sec
|
||
|
|
let ready_server = (hetzner_server_info $server.hostname)
|
||
|
|
print $"Server created successfully: ($ready_server.id)"
|
||
|
|
$ready_server
|
||
|
|
} else {
|
||
|
|
$created_server
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Delete server
|
||
|
|
export def hetzner_delete_server [settings: record, server: string, keep_storage: bool = true, error_exit: bool = true]: nothing -> nothing {
|
||
|
|
if not (hetzner_server_exists $server $error_exit) {
|
||
|
|
return null
|
||
|
|
}
|
||
|
|
|
||
|
|
print $"Deleting server: ($server)..."
|
||
|
|
|
||
|
|
if (hetzner_use_api) {
|
||
|
|
hetzner_api_delete_server $server
|
||
|
|
} else {
|
||
|
|
^hcloud server delete $server --yes | null
|
||
|
|
}
|
||
|
|
|
||
|
|
# Delete volumes if requested
|
||
|
|
if not $keep_storage {
|
||
|
|
hetzner_delete_server_storage $settings $server $error_exit
|
||
|
|
}
|
||
|
|
|
||
|
|
print $"Server deleted: ($server)"
|
||
|
|
null
|
||
|
|
}
|
||
|
|
|
||
|
|
# Delete server storage/volumes
|
||
|
|
export def hetzner_delete_server_storage [settings: record, server: string, error_exit: bool = true]: nothing -> nothing {
|
||
|
|
let server_info = (hetzner_server_info $server)
|
||
|
|
|
||
|
|
# Find volumes attached to this server
|
||
|
|
let volumes = (hetzner_api_list_volumes) | where {|v| ($v.server | default null) == $server_info.id}
|
||
|
|
|
||
|
|
print $"Deleting ($volumes | length) volume\(s\)..."
|
||
|
|
|
||
|
|
$volumes | each {|vol|
|
||
|
|
if (hetzner_use_api) {
|
||
|
|
hetzner_api_delete_volume ($vol.id | into string)
|
||
|
|
} else {
|
||
|
|
^hcloud volume delete $vol.name --yes | null
|
||
|
|
}
|
||
|
|
print $" Deleted volume: ($vol.name)"
|
||
|
|
}
|
||
|
|
|
||
|
|
print "All volumes deleted"
|
||
|
|
null
|
||
|
|
}
|
||
|
|
|
||
|
|
# Post-create server configuration (orchestrated via template)
|
||
|
|
export def hetzner_post_create_server [settings: record, server: record, check: bool = false]: nothing -> nothing {
|
||
|
|
if $check {
|
||
|
|
print "Check mode: Post-create configuration handled by template"
|
||
|
|
return null
|
||
|
|
}
|
||
|
|
|
||
|
|
# Create and attach additional_volumes (create-if-missing semantics, size_gb required)
|
||
|
|
# Volumes are declared under server.storage.additional_volumes in the Nickel schema
|
||
|
|
let extra_vols = ($server.storage?.additional_volumes? | default [])
|
||
|
|
if not ($extra_vols | is-empty) {
|
||
|
|
let server_info = (hetzner_server_info $server.hostname)
|
||
|
|
let existing = (hetzner_api_list_volumes)
|
||
|
|
print $"Creating/attaching ($extra_vols | length) additional volume(s)..."
|
||
|
|
$extra_vols | each {|vol|
|
||
|
|
let found = ($existing | where {|v| $v.name == $vol.name} | first | default null)
|
||
|
|
let volume_id = if $found != null {
|
||
|
|
print $" Volume already exists: ($vol.name) [($found.id)]"
|
||
|
|
$found.id | into string
|
||
|
|
} else {
|
||
|
|
let created = (hetzner_api_create_volume {
|
||
|
|
name: $vol.name
|
||
|
|
size: $vol.size_gb
|
||
|
|
location: $server.location
|
||
|
|
format: ($vol.type? | default "ext4")
|
||
|
|
automount: false
|
||
|
|
labels: ($server.labels? | default {})
|
||
|
|
})
|
||
|
|
print $" Created volume: ($vol.name) [($created.id)] ($vol.size_gb)GB"
|
||
|
|
$created.id | into string
|
||
|
|
}
|
||
|
|
# Only attach if not already attached to this server
|
||
|
|
let current_server = (hetzner_api_list_volumes | where {|v| $v.id == ($volume_id | into int)} | first | default null | get -o server | default null)
|
||
|
|
if $current_server == null {
|
||
|
|
hetzner_api_attach_volume $volume_id ($server_info.id | into string)
|
||
|
|
print $" Attached ($vol.name) → ($server.hostname)"
|
||
|
|
} else {
|
||
|
|
print $" ($vol.name) already attached to server ($current_server)"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Attach pre-existing volumes (attach-only, no creation)
|
||
|
|
let attach_vols = ($server.volumes? | default [])
|
||
|
|
if not ($attach_vols | is-empty) {
|
||
|
|
let server_info = (hetzner_server_info $server.hostname)
|
||
|
|
print "Attaching volumes..."
|
||
|
|
$attach_vols | each {|vol|
|
||
|
|
let volume_id = if ("id" in $vol) {
|
||
|
|
$vol.id | into string
|
||
|
|
} else {
|
||
|
|
let v = (hetzner_api_list_volumes | where {|v| $v.name == $vol.name} | first | default null)
|
||
|
|
if $v == null {
|
||
|
|
print $" ⚠ Volume not found: ($vol.name)"
|
||
|
|
return
|
||
|
|
}
|
||
|
|
$v.id | into string
|
||
|
|
}
|
||
|
|
hetzner_api_attach_volume $volume_id ($server_info.id | into string)
|
||
|
|
print $" Attached volume: ($vol.name)"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Configure networks if specified
|
||
|
|
if ("networks" in $server) and (not ($server.networks | is-empty)) {
|
||
|
|
let server_info = (hetzner_server_info $server.hostname)
|
||
|
|
print "Attaching networks..."
|
||
|
|
$server.networks | each {|net|
|
||
|
|
let network_id = if ("id" in $net) {
|
||
|
|
$net.id
|
||
|
|
} else {
|
||
|
|
let n = (hetzner_api_list_networks) | where {|n| $n.name == $net.name} | first
|
||
|
|
$n.id
|
||
|
|
}
|
||
|
|
|
||
|
|
let ip = if ("ip" in $net) { $net.ip } else { null }
|
||
|
|
hetzner_api_attach_network ($server_info.id | into string) ($network_id | into string) $ip
|
||
|
|
print $" Attached network: ($net.name)"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Assign floating IP if declared in server config
|
||
|
|
let fip_name = ($server | get -o floating_ip | default "")
|
||
|
|
if ($fip_name | is-not-empty) {
|
||
|
|
print $"Assigning floating IP ($fip_name) to ($server.hostname)..."
|
||
|
|
let fips = (hetzner_api_list_floating_ips)
|
||
|
|
let matches = ($fips | where {|f| $f.name == $fip_name})
|
||
|
|
if ($matches | is-empty) {
|
||
|
|
print $" ⚠ Floating IP '($fip_name)' not found in Hetzner — create it first with hcloud floating-ip create"
|
||
|
|
} else {
|
||
|
|
let fip = ($matches | first)
|
||
|
|
let server_info = (hetzner_server_info $server.hostname)
|
||
|
|
hetzner_api_assign_floating_ip ($fip.id | into string) ($server_info.id | into string)
|
||
|
|
print $" ✓ Assigned ($fip_name) [($fip.id)] → ($server.hostname) [($server_info.id)]"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
null
|
||
|
|
}
|
||
|
|
|
||
|
|
# Modify server. Supports changing server_type (with keep_disk semantics) and name.
|
||
|
|
# new_values.keep_disk (default false) controls whether the change_type action upgrades
|
||
|
|
# the disk: keep_disk=true sends upgrade_disk=false to preserve original disk size.
|
||
|
|
# The Hetzner API requires the server to be off before change_type — callers wanting the
|
||
|
|
# full flow should use hetzner_resize_server which handles power_off/on around it.
|
||
|
|
export def hetzner_modify_server [settings: record, server: string, new_values: record, error_exit: bool = true]: nothing -> record {
|
||
|
|
let server_info = (hetzner_server_info $server)
|
||
|
|
let keep_disk = ($new_values | get -o keep_disk | default false)
|
||
|
|
|
||
|
|
if ("server_type" in $new_values) {
|
||
|
|
print $"Changing server type to ($new_values.server_type) \(keep_disk=($keep_disk)\)..."
|
||
|
|
let data = { upgrade_disk: (not $keep_disk) }
|
||
|
|
hetzner_api_request "POST" $"/servers/($server_info.id)/actions/change_type?type=($new_values.server_type)" $data | null
|
||
|
|
}
|
||
|
|
|
||
|
|
if ("name" in $new_values) {
|
||
|
|
print $"Renaming server to ($new_values.name)..."
|
||
|
|
hetzner_api_request "POST" $"/servers/($server_info.id)/actions/change_name?new_name=($new_values.name)" {} | null
|
||
|
|
}
|
||
|
|
|
||
|
|
hetzner_server_info $server
|
||
|
|
}
|
||
|
|
|
||
|
|
# Power off a server and poll until status=="off" or the deadline elapses.
|
||
|
|
# Idempotent: returns immediately when the server is already off.
|
||
|
|
# Default deadline 30s matches the reference impl in libre-forge's fleet protocol;
|
||
|
|
# tune via timeout_sec for slower regions or stuck shutdowns.
|
||
|
|
export def hetzner_power_off_wait [server: string, timeout_sec: int = 30, poll_ms: int = 500]: nothing -> record {
|
||
|
|
let info = (hetzner_server_info $server)
|
||
|
|
if $info.status == "off" { return $info }
|
||
|
|
|
||
|
|
print $"Powering off server: ($server)..."
|
||
|
|
if (hetzner_use_api) {
|
||
|
|
hetzner_api_server_action ($info.id | into string) "stop"
|
||
|
|
} else {
|
||
|
|
^hcloud server poweroff $server | null
|
||
|
|
}
|
||
|
|
|
||
|
|
let deadline_ms = ((date now | format date "%s%3f" | into int) + $timeout_sec * 1000)
|
||
|
|
mut current = (hetzner_server_info $server)
|
||
|
|
while ($current.status != "off") {
|
||
|
|
let now_ms = (date now | format date "%s%3f" | into int)
|
||
|
|
if ($now_ms >= $deadline_ms) {
|
||
|
|
throw-error $"power_off timed out after ($timeout_sec)s — server '($server)' status=($current.status)"
|
||
|
|
}
|
||
|
|
sleep ($poll_ms | into duration --unit ms)
|
||
|
|
$current = (hetzner_server_info $server)
|
||
|
|
}
|
||
|
|
$current
|
||
|
|
}
|
||
|
|
|
||
|
|
# Power on a server. Fire-and-forget — returns immediately after issuing the action;
|
||
|
|
# does not wait for status=="running". Idempotent: no-op when already running.
|
||
|
|
export def hetzner_power_on [server: string]: nothing -> record {
|
||
|
|
let info = (hetzner_server_info $server)
|
||
|
|
if $info.status == "running" { return $info }
|
||
|
|
|
||
|
|
print $"Powering on server: ($server)..."
|
||
|
|
if (hetzner_use_api) {
|
||
|
|
hetzner_api_server_action ($info.id | into string) "start"
|
||
|
|
} else {
|
||
|
|
^hcloud server poweron $server | null
|
||
|
|
}
|
||
|
|
hetzner_server_info $server
|
||
|
|
}
|
||
|
|
|
||
|
|
# Resize a server with the full Hetzner change_type flow:
|
||
|
|
# power_off (poll until off, 30s budget) → change_type (upgrade_disk=!keep_disk) → power_on.
|
||
|
|
# This is the canonical AlwaysOnResize-strategy path; keep_disk=true preserves the
|
||
|
|
# original disk allocation when downsizing (Hetzner permits this only with the smaller
|
||
|
|
# server_type's disk meeting/exceeding the original allocation).
|
||
|
|
export def hetzner_resize_server [
|
||
|
|
server: string,
|
||
|
|
target_type: string,
|
||
|
|
keep_disk: bool = true,
|
||
|
|
settings: record = {},
|
||
|
|
]: nothing -> record {
|
||
|
|
print $"Resizing server ($server) → ($target_type) \(keep_disk=($keep_disk)\)..."
|
||
|
|
hetzner_power_off_wait $server | null
|
||
|
|
hetzner_modify_server $settings $server { server_type: $target_type, keep_disk: $keep_disk } | null
|
||
|
|
hetzner_power_on $server | null
|
||
|
|
hetzner_server_info $server
|
||
|
|
}
|
||
|
|
|
||
|
|
# Change server state (start, stop, restart, reboot, shutdown)
|
||
|
|
export def hetzner_server_state [server: string, new_state: string, error_exit: bool = true, wait: bool = true, settings: record = {}]: nothing -> record {
|
||
|
|
let valid_states = ["start", "stop", "restart", "reboot", "shutdown"]
|
||
|
|
|
||
|
|
if not ($new_state in $valid_states) {
|
||
|
|
throw-error $"Invalid state: ($new_state). Valid states: ($valid_states | str join ', ')"
|
||
|
|
}
|
||
|
|
|
||
|
|
let server_info = (hetzner_server_info $server)
|
||
|
|
print $"($new_state | str capitalize) server: ($server)..."
|
||
|
|
|
||
|
|
if (hetzner_use_api) {
|
||
|
|
hetzner_api_server_action ($server_info.id | into string) $new_state
|
||
|
|
} else {
|
||
|
|
^hcloud server $new_state $server | null
|
||
|
|
}
|
||
|
|
|
||
|
|
if $wait {
|
||
|
|
sleep 3sec
|
||
|
|
}
|
||
|
|
|
||
|
|
hetzner_server_info $server
|
||
|
|
}
|
||
|
|
|
||
|
|
# Get IP address from server via hcloud CLI
|
||
|
|
# REVIEW(TASK-7): Replace with HTTP GET /api/v1/servers/{server}/ip?type={ip_type} via orchestrator
|
||
|
|
# ipv4 branch uses hcloud_server_info (already marked), ipv6/private still call ^hcloud directly
|
||
|
|
export def hetzner_get_ip [settings: record, server: string, ip_type: string = "ipv4", error_exit: bool = true]: nothing -> string {
|
||
|
|
match $ip_type {
|
||
|
|
"ipv4" | "public" => {
|
||
|
|
# Use static IP from config when available — avoids an API round-trip
|
||
|
|
let static_ip = ($settings | get -o data.servers | default []
|
||
|
|
| where {|s| $s.hostname == $server}
|
||
|
|
| get 0? | get -o networking.public_ip | default null)
|
||
|
|
if ($static_ip | is-not-empty) and $static_ip != null {
|
||
|
|
return ($static_ip | into string)
|
||
|
|
}
|
||
|
|
let info = (hcloud_server_info $server)
|
||
|
|
if ($info | is-empty) {
|
||
|
|
if $error_exit { throw-error $"Server not found: ($server)" }
|
||
|
|
""
|
||
|
|
} else {
|
||
|
|
$info.ipv4
|
||
|
|
}
|
||
|
|
}
|
||
|
|
"ipv6" => {
|
||
|
|
let res = (^hcloud server describe $server -o $"format={{.PublicNet.IPv6.IP}}" | complete)
|
||
|
|
if $res.exit_code != 0 {
|
||
|
|
if $error_exit { throw-error $"Server not found: ($server)" }
|
||
|
|
""
|
||
|
|
} else {
|
||
|
|
$res.stdout | str trim
|
||
|
|
}
|
||
|
|
}
|
||
|
|
"private" => {
|
||
|
|
let res = (^hcloud server describe $server -o $"format={{(index .PrivateNet 0).IP}}" | complete)
|
||
|
|
if $res.exit_code != 0 { "" } else { $res.stdout | str trim }
|
||
|
|
}
|
||
|
|
"floating_ip" | "fip" => {
|
||
|
|
# Resolve FIP name from settings, then fetch its IP from Hetzner
|
||
|
|
let fip_name = ($settings | get -o data.servers | default []
|
||
|
|
| where {|s| $s.hostname == $server}
|
||
|
|
| get 0? | get -o floating_ip | default "")
|
||
|
|
if ($fip_name | is-empty) {
|
||
|
|
if $error_exit { error make { msg: $"No floating_ip declared for server ($server)" } }
|
||
|
|
""
|
||
|
|
} else {
|
||
|
|
let res = (do { ^hcloud floating-ip describe $fip_name -o json } | complete)
|
||
|
|
if $res.exit_code != 0 {
|
||
|
|
if $error_exit { error make { msg: $"Floating IP '($fip_name)' not found in Hetzner" } }
|
||
|
|
""
|
||
|
|
} else {
|
||
|
|
$res.stdout | from json | get -o ip | default ""
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
_ => { error make { msg: $"Invalid IP type: ($ip_type)" } }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Get IPs for multiple servers
|
||
|
|
export def hetzner_servers_ips [settings: record, data: any, prov: string = "hetzner", serverpos: int = 0]: nothing -> list {
|
||
|
|
let servers = if ($data | describe) == "list" {
|
||
|
|
$data
|
||
|
|
} else if ($data | describe) == "string" {
|
||
|
|
[$data]
|
||
|
|
} else {
|
||
|
|
[]
|
||
|
|
}
|
||
|
|
|
||
|
|
$servers | each {|s|
|
||
|
|
let server_name = if ($s | describe) == "string" {
|
||
|
|
$s
|
||
|
|
} else if ("hostname" in $s) {
|
||
|
|
$s.hostname
|
||
|
|
} else if ("name" in $s) {
|
||
|
|
$s.name
|
||
|
|
} else {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
|
||
|
|
let ipv4 = (hetzner_get_ip $settings $server_name "ipv4" false)
|
||
|
|
let ipv6 = (hetzner_get_ip $settings $server_name "ipv6" false)
|
||
|
|
|
||
|
|
{
|
||
|
|
name: $server_name
|
||
|
|
ipv4: $ipv4
|
||
|
|
ipv6: $ipv6
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|