# infra-catalog Runner Interface — ComputeProvider contract # # Defines the contract that any compute-provider component must satisfy. # A conforming implementation is a script (typically runner.nu in the component # root) that accepts subcommands with JSON on stdin and writes JSON to stdout. # # Invocation pattern (callers always go through `runner-dispatch`): # {cpu: 4, memory_gb: 8, ...} | runner-dispatch "/path/to/provider/runner.nu" spawn # # Implementors source this module to validate their own I/O: # use catalog/runner.nu [validate-spawn-input, validate-spawn-output] # # The interface is intentionally narrow: lian-build, Vapora, and any other tool # that needs ephemeral compute only need these four commands. Provider-specific # operations (snapshot management, network topology, etc.) belong in the # provider's own extended interface, not here. # ── Input / output shapes ───────────────────────────────────────────────────── # Shape of the JSON record passed to `spawn`. # All fields that carry provider-specific meaning are string references: # the caller does not interpret them — the provider script does. export def spawn-input-shape []: nothing -> record { { cpu: { type: "int", required: true, doc: "Minimum CPU cores." } memory_gb: { type: "int", required: true, doc: "Minimum RAM in GB." } disk_gb: { type: "int", required: true, doc: "Minimum root disk in GB." } time_budget_min: { type: "int", required: true, doc: "Expected lifetime. Provider may enforce TTL." } image_ref: { type: "string", required: true, doc: "Provider-specific image/snapshot reference." } location: { type: "string", required: true, doc: "Provider-specific region or datacenter." } ssh_key_ref: { type: "string", required: true, doc: "SSH key name registered with the provider." } network_ref: { type: "string", required: false, doc: "Private network name or null." } firewall_ref: { type: "string", required: false, doc: "Firewall name or null." } tags: { type: "record", required: false, doc: "Arbitrary key-value labels." } } } # Shape of the JSON record returned by `spawn`. # `handle` is opaque to the caller — it is stored and returned verbatim # to `destroy` and `status`. Providers encode whatever they need in it # (server name, instance ID, etc.). export def spawn-output-shape []: nothing -> record { { handle: { type: "string", required: true, doc: "Opaque provider reference. Returned to destroy/status." } ssh_host: { type: "string", required: true, doc: "IP or hostname to reach the runner." } ssh_port: { type: "int", required: true, doc: "SSH port." } expires_at: { type: "string", required: false, doc: "ISO-8601 expiry, or null if provider has no TTL." } } } # Shape passed to `destroy` and `status`. export def handle-input-shape []: nothing -> record { { handle: { type: "string", required: true, doc: "Value returned by spawn." } } } # Shape returned by `status`. export def status-output-shape []: nothing -> record { { running: { type: "bool", required: true, doc: "Whether the runner is reachable." } ssh_host: { type: "string", required: false, doc: "Current IP, if available." } } } # Shape returned by `describe`. export def describe-output-shape []: nothing -> record { { locations: { type: "list", required: true, doc: "Available regions/datacenters." } server_types: { type: "list", required: true, doc: "Available sizes. Each record: {name, cpu, memory_gb, disk_gb}." } image_query: { type: "string", required: false, doc: "How to discover available images, or null." } } } # ── Validators ──────────────────────────────────────────────────────────────── # Validate a spawn input record against the contract. # Errors immediately with a descriptive message on contract violation. export def validate-spawn-input []: record -> record { let input = $in let required = [cpu, memory_gb, disk_gb, time_budget_min, image_ref, location, ssh_key_ref] let missing = $required | where { |f| not ($f in ($input | columns)) } if not ($missing | is-empty) { error make { msg: $"runner spawn: missing required fields: ($missing | str join ', ')" } } if ($input.cpu | into int) < 1 { error make { msg: "runner spawn: cpu must be >= 1" } } if ($input.memory_gb | into int) < 1 { error make { msg: "runner spawn: memory_gb must be >= 1" } } $input } # Validate a spawn output record before returning it to the caller. export def validate-spawn-output []: record -> record { let output = $in let required = [handle, ssh_host, ssh_port] let missing = $required | where { |f| not ($f in ($output | columns)) } if not ($missing | is-empty) { error make { msg: $"runner spawn output: missing required fields: ($missing | str join ', ')" } } $output } # ── Dispatcher ─────────────────────────────────────────────────────────────── # # The dispatcher is the single point through which all tools invoke a provider. # It isolates callers from the path and invocation mechanics of the provider # script. Adding or replacing a provider never requires changes in the caller. # # Usage: # {cpu: 4, memory_gb: 8, disk_gb: 20, time_budget_min: 60, # image_ref: "buildkit-runner-latest", location: "fsn1", # ssh_key_ref: "orchestrator-buildkit"} # | runner-dispatch "/catalog/providers/hetzner/runner.nu" spawn # # {handle: "buildkit-runner-abc123"} | runner-dispatch "/catalog/providers/hetzner/runner.nu" destroy export def runner-dispatch [ script: path, # absolute path to the provider's runner.nu command: string, # spawn | destroy | status | describe ]: record -> any { let input = $in if not ($script | path exists) { error make { msg: $"runner-dispatch: provider script not found: ($script)" } } let allowed = [spawn destroy status describe] if not ($command in $allowed) { error make { msg: $"runner-dispatch: unknown command '($command)'. Allowed: ($allowed | str join ', ')" } } let json_input = $input | to json let result = $json_input | nu $script $command | complete if $result.exit_code != 0 { error make { msg: $"runner ($command) failed (exit ($result.exit_code))" label: { text: ($result.stderr | str trim) span: (metadata $script).span } } } if $command in [spawn status describe] { $result.stdout | str trim | from json } }