#!/usr/bin/env nu # Hcloud adapter for lian-build runner dispatch. # Protocol: nu spawn-runner.nu — JSON on stdin, JSON on stdout. # Contract: lian-build/schemas/adapter.ncl use std log const GOLDEN_IMAGE_PREFIX = "buildkit-runner-golden" const DEFAULT_SSH_PORT = 22 # ── Subcommands ─────────────────────────────────────────────────────────────── # Spawn an ephemeral hcloud VM. Reads SpawnInput JSON from stdin. # Returns SpawnOutput JSON: {handle, ssh_host, ssh_port, expires_at} def "main spawn" [] { let input = (^cat | from json) let image_id = _resolve_image $input.image_ref let server_name = $"buildkit-runner-(random uuid | str substring 0..7)" let expires_at = (date now) + ($input.time_budget_min * 60sec) | format date "%Y-%m-%dT%H:%M:%SZ" let server_type = _server_type $input.cpu $input.memory_gb log info $"Spawning ($server_name) type=($server_type) location=($input.location)…" let base_args = [ "server", "create", "--name", $server_name, "--image", ($image_id | into string), "--type", $server_type, "--location", $input.location, "--label", "app=buildkit-runner", "--label", $"expires_at=($expires_at)", ] let with_key = if ($input.ssh_key_ref | is-not-empty) { $base_args | append ["--ssh-key", $input.ssh_key_ref] } else { $base_args } let with_net = if (($input.network_ref? | default "") | is-not-empty) { $with_key | append ["--network", ($input.network_ref?)] } else { $with_key } let create_args = if (($input.firewall_ref? | default "") | is-not-empty) { $with_net | append ["--firewall", ($input.firewall_ref?)] } else { $with_net } let create_out = do { ^hcloud ...$create_args } | complete if $create_out.exit_code != 0 { error make { msg: $"hcloud server create failed:\n($create_out.stderr)" } } let describe_out = do { ^hcloud server describe $server_name --output json } | complete if $describe_out.exit_code != 0 { error make { msg: $"hcloud server describe ($server_name) failed:\n($describe_out.stderr)" } } let server = $describe_out.stdout | from json let server_ip = $server.public_net.ipv4.ip log info $"Server id=($server.id) ip=($server_ip) — waiting for SSH…" _wait_for_ssh $server_ip 180 let dns = $input.internal_dns? | default "" if ($dns | is-not-empty) { _inject_resolv_conf $server_ip $dns } { handle: $server_name, ssh_host: $server_ip, ssh_port: $DEFAULT_SSH_PORT, expires_at: $expires_at } | to json } # Destroy a runner by handle. Reads DestroyInput JSON {handle} from stdin. def "main destroy" [] { let input = (^cat | from json) let handle = $input.handle log info $"Destroying runner ($handle)…" let out = do { ^hcloud server delete $handle } | complete if $out.exit_code != 0 { error make { msg: $"hcloud server delete ($handle) failed:\n($out.stderr)" } } } # List all buildkit-runner instances managed by this adapter. def "main list" [] { let out = do { ^hcloud server list --selector app=buildkit-runner --output json } | complete if $out.exit_code != 0 { error make { msg: $"hcloud server list failed:\n($out.stderr)" } } $out.stdout | from json | each {|s| { handle: $s.name, ssh_host: $s.public_net.ipv4.ip, ssh_port: $DEFAULT_SSH_PORT, status: (if $s.status == "running" { "running" } else if $s.status == "initializing" { "starting" } else { "unknown" }), created_at: $s.created, } } | to json } def main [] { print "Usage: spawn-runner.nu " } # ── Helpers ─────────────────────────────────────────────────────────────────── def _resolve_image [image_ref: string] { let by_name = do { ^hcloud image list --type snapshot --name $image_ref --output json } | complete if $by_name.exit_code == 0 { let imgs = $by_name.stdout | from json if ($imgs | length) > 0 { return ($imgs | first).id } } let by_label = do { ^hcloud image list --type snapshot --selector $"app=($GOLDEN_IMAGE_PREFIX)" --output json } | complete if $by_label.exit_code != 0 { error make { msg: $"hcloud image list failed:\n($by_label.stderr)" } } let imgs = $by_label.stdout | from json if ($imgs | length) == 0 { error make { msg: $"No snapshot found — image_ref=($image_ref), label app=($GOLDEN_IMAGE_PREFIX). Run build-golden-image.nu first." } } ($imgs | sort-by created | last).id } def _server_type [cpu: int, memory_gb: int] { if $cpu <= 2 { "cax11" } else if $cpu <= 4 { "cax21" } else if $cpu <= 8 { "cax31" } else { "cax41" } } def _wait_for_ssh [host: string, timeout_sec: int] { let deadline = (date now) + ($timeout_sec * 1sec) loop { let r = do { ^ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 -o BatchMode=yes $"root@($host)" true } | complete if $r.exit_code == 0 { return } if (date now) > $deadline { error make { msg: $"Timeout waiting for SSH on ($host)" } } sleep 5sec } } def _inject_resolv_conf [host: string, dns_ip: string] { let r = do { ^ssh -o StrictHostKeyChecking=no -o BatchMode=yes $"root@($host)" $"printf 'nameserver ($dns_ip)\\n' > /etc/resolv.conf" } | complete if $r.exit_code != 0 { log warning $"resolv.conf inject failed on ($host): ($r.stderr)" } }