provisioning-core/nulib/catalog/runner.nu

155 lines
6.8 KiB
Text

# 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<string>", required: true, doc: "Available regions/datacenters." }
server_types: {
type: "list<record>",
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
}
}