provisioning-catalog/providers/hetzner/nulib/hetzner/servers.nu

556 lines
22 KiB
Text
Raw Normal View History

# 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
}
}
}