provisioning-catalog/providers/hetzner/runner.nu

213 lines
8.5 KiB
Text
Raw Normal View History

# Hetzner Cloud ComputeProvider runner.
#
# Invocation (via runner-dispatch):
# <json_input> | nu runner.nu <command>
#
# Commands: spawn | destroy | status | describe
# Input: JSON record on stdin matching the interface contract
# Output: JSON record on stdout (spawn/status/describe); silent (destroy)
# Errors: human-readable message to stderr, non-zero exit
# ── Server type resolution ────────────────────────────────────────────────────
def select-server-type [cpu: int, memory_gb: int]: nothing -> string {
let types = [
{ name: "cax11", cpu: 2, memory_gb: 4 }
{ name: "cax21", cpu: 4, memory_gb: 8 }
{ name: "cax31", cpu: 8, memory_gb: 16 }
{ name: "cax41", cpu: 16, memory_gb: 32 }
]
let fit = ($types | where { |t| $t.cpu >= $cpu and $t.memory_gb >= $memory_gb })
if ($fit | is-empty) {
error make { msg: $"No CAX type satisfies cpu=($cpu) memory_gb=($memory_gb). Maximum: cax41 16cpu/32GB" }
}
($fit | first).name
}
# ── Cloud-init generation ─────────────────────────────────────────────────────
# Generates a cloud-config that configures systemd-resolved to use a private DNS
# server. Written to a temp file and passed to hcloud via --user-data-from-file.
def generate-cloud-init [dns_ip: string]: nothing -> string {
$"#cloud-config
write_files:
- path: /etc/systemd/resolved.conf.d/10-private-dns.conf
content: |
[Resolve]
DNS=($dns_ip)
FallbackDNS=1.1.1.1 8.8.8.8
owner: root:root
permissions: '0644'
runcmd:
- systemctl restart systemd-resolved
- ip route replace 10.200.2.0/24 via 10.0.8.1 || true
- grep -qF 'reg.librecloud.online' /etc/hosts || echo '10.200.2.253 reg.librecloud.online termas.librecloud.online' >> /etc/hosts
- grep -qF 'daoreg.librecloud.online' /etc/hosts || echo '10.0.8.6 daoreg.librecloud.online daotermas.librecloud.online' >> /etc/hosts
"
}
# ── Image resolution ──────────────────────────────────────────────────────────
# image_ref containing '=' is a label selector; otherwise treated as ID/name.
def resolve-image [image_ref: string]: nothing -> string {
if ($image_ref | str contains "=") {
let r = (do { ^hcloud image list --selector $image_ref --type snapshot --output json } | complete)
if $r.exit_code != 0 {
error make { msg: $"hcloud image list failed: ($r.stderr | str trim)" }
}
let imgs = ($r.stdout | from json)
if ($imgs | is-empty) {
error make { msg: $"No snapshot found with selector: ($image_ref)" }
}
($imgs | sort-by created | last).id | into string
} else {
$image_ref
}
}
# ── SSH port readiness ────────────────────────────────────────────────────────
# Polls TCP port every 5 seconds up to max_attempts times.
def wait-for-port [ip: string, port: int, max_attempts: int]: nothing -> nothing {
mut attempt = 0
mut open = false
while $attempt < $max_attempts and (not $open) {
let r = (do { ^nc -z -w 3 $ip $port } | complete)
if $r.exit_code == 0 {
$open = true
} else {
sleep 5sec
$attempt = $attempt + 1
}
}
if not $open {
error make { msg: $"TCP port ($port) on ($ip) unreachable after ($max_attempts) attempts (~($max_attempts * 5)s)" }
}
}
# ── spawn ─────────────────────────────────────────────────────────────────────
def cmd-spawn [input: record]: nothing -> record {
let server_type = (select-server-type $input.cpu $input.memory_gb)
let image_id = (resolve-image $input.image_ref)
let ts = (date now | format date "%Y%m%d%H%M%S")
let rand = (random int 1000..9999)
let server_name = $"runner-($ts)-($rand)"
mut args = [
"server" "create"
"--name" $server_name
"--type" $server_type
"--image" $image_id
"--location" $input.location
"--ssh-key" $input.ssh_key_ref
"--output" "json"
]
let network = (try { $input.network_ref } catch { null })
let firewall = (try { $input.firewall_ref } catch { null })
let internal_dns = (try { $input.internal_dns } catch { null })
if ($network != null) {
$args = ($args | append ["--network" $network])
}
if ($firewall != null) {
$args = ($args | append ["--firewall" $firewall])
}
let cloud_init_file = if ($internal_dns != null) {
let f = (mktemp --suffix .yaml)
(generate-cloud-init $internal_dns) | save --force $f
$f
} else {
null
}
if ($cloud_init_file != null) {
$args = ($args | append ["--user-data-from-file" $cloud_init_file])
}
let create_args = $args
let r = (do { ^hcloud ...$create_args } | complete)
if ($cloud_init_file != null) {
^rm -f $cloud_init_file
}
if $r.exit_code != 0 {
error make { msg: $"hcloud server create failed: ($r.stderr | str trim)" }
}
let created = ($r.stdout | from json)
let server_id = ($created.server.id | into string)
let ip = $created.server.public_net.ipv4.ip
wait-for-port $ip 22 36
{
handle: ({ name: $server_name, id: $server_id } | to json)
ssh_host: $ip
ssh_port: 22
expires_at: null
}
}
# ── destroy ───────────────────────────────────────────────────────────────────
def cmd-destroy [input: record]: nothing -> nothing {
let h = ($input.handle | from json)
let r = (do { ^hcloud server delete $h.name } | complete)
if $r.exit_code != 0 {
error make { msg: $"hcloud server delete failed: ($r.stderr | str trim)" }
}
}
# ── status ────────────────────────────────────────────────────────────────────
def cmd-status [input: record]: nothing -> record {
let h = ($input.handle | from json)
let r = (do { ^hcloud server describe $h.name --output json } | complete)
if $r.exit_code != 0 {
if ($r.stderr | str contains "not found") {
return { running: false, ssh_host: null }
}
error make { msg: $"hcloud server describe failed: ($r.stderr | str trim)" }
}
let srv = ($r.stdout | from json)
let running = ($srv.status == "running")
let ip = (try { $srv.public_net.ipv4.ip } catch { null })
{ running: $running, ssh_host: $ip }
}
# ── describe ──────────────────────────────────────────────────────────────────
def cmd-describe []: nothing -> record {
{
locations: ["fsn1" "nbg1" "hel1" "ash" "hil"]
server_types: [
{ name: "cax11", cpu: 2, memory_gb: 4, disk_gb: 40 }
{ name: "cax21", cpu: 4, memory_gb: 8, disk_gb: 80 }
{ name: "cax31", cpu: 8, memory_gb: 16, disk_gb: 160 }
{ name: "cax41", cpu: 16, memory_gb: 32, disk_gb: 320 }
]
image_query: "hcloud image list --type snapshot --selector app=buildkit-runner-golden --output json"
}
}
# ── entry point ───────────────────────────────────────────────────────────────
def main [command: string] {
let input = (^cat | from json)
match $command {
"spawn" => { cmd-spawn $input | to json | print }
"destroy" => { cmd-destroy $input }
"status" => { cmd-status $input | to json | print }
"describe" => { cmd-describe | to json | print }
_ => {
error make { msg: $"Unknown command: ($command). Allowed: spawn destroy status describe" }
}
}
}