212 lines
8.5 KiB
Text
212 lines
8.5 KiB
Text
# 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" }
|
|
}
|
|
}
|
|
}
|