- DAG architecture: `dag show/validate/export` (nulib/main_provisioning/dag.nu),
config loader (lib_provisioning/config/loader/dag.nu), taskserv dag-executor.
Backed by schemas/lib/dag/*.ncl; orchestrator emits NATS events via
WorkspaceComposition::into_workflow. See ADR-020, ADR-021.
- Unified Component Architecture: components/mod.nu, main_provisioning/
{components,workflow,extensions,ontoref-queries}.nu. Full workflow engine with
topological sort and NATS subject emission. Blocks A-H complete (libre-daoshi).
- Commands-registry: nulib/commands-registry.ncl (Nickel source, 314 lines) +
JSON cache at ~/.cache/provisioning/commands-registry.json rebuilt on source
change. cli/provisioning fast-path alias expansion avoids cold Nu startup.
ADDING_COMMANDS.md documents new-command workflow.
- Platform service manager: service-manager.nu (+573), startup.nu (+611),
service-check.nu (+255); autostart/bootstrap/health/target refactored.
- Nushell 0.112.2 migration: removed all try/catch and bash redirections;
external commands prefixed with ^; type signatures enforced. Driven by
scripts/refactor-try-catch{,-simplified}.nu.
- TTY stack: removed shlib/*-tty.sh; replaced by cli/tty-dispatch.sh,
tty-filter.sh, tty-commands.conf.
- New domain modules: images/ (golden image lifecycle), workspace/{state,sync}.nu,
main_provisioning/{bootstrap,cluster-deploy,fip,state}.nu, commands/{state,
build,integrations/auth,utilities/alias}.nu, platform.nu expanded (+874).
- Config loader overhaul: loader/core.nu slimmed (-759), cache/core.nu
refactored (-454), removed legacy loaders/file_loader.nu (-330).
- Thirteen new provisioning-<domain>.nu top-level modules for bash dispatcher.
- Tests: test_workspace_state.nu (+351); updates to test_oci_registry,
test_services.
- README + CHANGELOG updated.
268 lines
11 KiB
Text
268 lines
11 KiB
Text
use ../lib_provisioning/workspace *
|
|
use ../lib_provisioning/user/config.nu [get-workspace-path, get-active-workspace-details]
|
|
use ../../../extensions/providers/hetzner/nulib/hetzner/api.nu *
|
|
use ../lib_provisioning/utils/nickel_processor.nu [ncl-eval, ncl-eval-soft]
|
|
|
|
# Export a Nickel file relative to the workspace root, with provisioning import path.
|
|
def bootstrap-ncl-export [ws_root: string, rel_path: string]: nothing -> record {
|
|
let prov_root = ($env.PROVISIONING? | default "/usr/local/provisioning")
|
|
let full_path = ($ws_root | path join $rel_path)
|
|
let result = (ncl-eval $full_path [$ws_root $prov_root])
|
|
$result
|
|
}
|
|
|
|
# Ensure the private network exists and all declared subnets are present.
|
|
# Creates the network if absent; reconciles subnets for existing networks.
|
|
def bootstrap-network [cfg: record]: nothing -> record {
|
|
let existing = (hetzner_api_list_networks | where name == $cfg.name)
|
|
let network = if ($existing | is-not-empty) {
|
|
print $" network ($cfg.name) already exists — skip"
|
|
($existing | first)
|
|
} else {
|
|
print $" creating network ($cfg.name) ..."
|
|
let payload = { name: $cfg.name, ip_range: $cfg.ip_range, subnets: ($cfg.subnets? | default []) }
|
|
let payload = if ("labels" in ($cfg | columns)) {
|
|
$payload | insert labels $cfg.labels
|
|
} else {
|
|
$payload
|
|
}
|
|
let created = (hetzner_api_create_network $payload)
|
|
|
|
let delete_protected = ($cfg | get -o protection.delete | default false)
|
|
if $delete_protected {
|
|
print $" enabling delete protection on ($cfg.name) ..."
|
|
let _action = (hetzner_api_network_change_protection ($created.id | into string) true)
|
|
}
|
|
$created
|
|
}
|
|
|
|
# Reconcile subnets: add any declared subnets that are missing from the network.
|
|
let declared = ($cfg.subnets? | default [])
|
|
if ($declared | is-not-empty) {
|
|
let network_detail = (hetzner_api_network_info ($network.id | into string))
|
|
let existing_ranges = ($network_detail.subnets? | default [] | each { |s| $s.ip_range })
|
|
for sn in $declared {
|
|
if not ($existing_ranges | any { |r| $r == $sn.ip_range }) {
|
|
print $" adding subnet ($sn.ip_range) to ($cfg.name) ..."
|
|
let _action = (hetzner_api_network_add_subnet ($network.id | into string) $sn)
|
|
print $" ✓ subnet ($sn.ip_range) added"
|
|
} else {
|
|
print $" subnet ($sn.ip_range) already present — skip"
|
|
}
|
|
}
|
|
}
|
|
|
|
$network
|
|
}
|
|
|
|
# Ensure the SSH key exists in Hetzner Cloud, importing it if absent. Returns the ssh_key record.
|
|
def bootstrap-ssh-key [cfg: record]: nothing -> record {
|
|
let existing = (hetzner_api_list_ssh_keys | where name == $cfg.name)
|
|
if ($existing | is-not-empty) {
|
|
print $" ssh_key ($cfg.name) already exists — skip"
|
|
return ($existing | first)
|
|
}
|
|
let key_path = ($cfg.public_key_path | str replace "~" $nu.home-dir)
|
|
if (($key_path | path exists) == false) {
|
|
error make { msg: $"SSH public key not found at ($key_path)" }
|
|
}
|
|
let public_key = (open $key_path | str trim)
|
|
print $" importing ssh_key ($cfg.name) ..."
|
|
hetzner_api_create_ssh_key $cfg.name $public_key
|
|
}
|
|
|
|
# Ensure the firewall exists, creating it if absent. Returns the firewall record.
|
|
def bootstrap-firewall [cfg: record]: nothing -> record {
|
|
let existing = (hetzner_api_list_firewalls | where name == $cfg.name)
|
|
if ($existing | is-not-empty) {
|
|
print $" firewall ($cfg.name) already exists — skip"
|
|
return ($existing | first)
|
|
}
|
|
print $" creating firewall ($cfg.name) ..."
|
|
let payload = { name: $cfg.name, rules: $cfg.rules }
|
|
let payload = if ("labels" in ($cfg | columns)) {
|
|
$payload | insert labels $cfg.labels
|
|
} else {
|
|
$payload
|
|
}
|
|
hetzner_api_create_firewall $payload
|
|
}
|
|
|
|
# Ensure a Floating IP exists, creating it if absent. Returns {id, record}.
|
|
def bootstrap-floating-ip [fip: record]: nothing -> record {
|
|
let existing = (hetzner_api_list_floating_ips | where name == $fip.name)
|
|
if ($existing | is-not-empty) {
|
|
let found = ($existing | first)
|
|
print $" floating_ip ($fip.name) already exists \(id: ($found.id)\) — skip"
|
|
return { id: ($found.id | into string), record: $found }
|
|
}
|
|
print $" creating floating_ip ($fip.name) ..."
|
|
let description = ($fip | get -o description | default "")
|
|
let labels = ($fip | get -o labels | default {})
|
|
let payload = {
|
|
type: $fip.type,
|
|
home_location: ($fip.location? | default ($fip.home_location? | default "")),
|
|
name: $fip.name,
|
|
description: $description,
|
|
labels: $labels,
|
|
}
|
|
let created = (hetzner_api_create_floating_ip $payload)
|
|
let fip_id = ($created.id | into string)
|
|
|
|
let has_ptr = ("dns_ptr" in ($fip | columns)) and (($fip.dns_ptr | is-empty) == false)
|
|
if $has_ptr {
|
|
print $" setting PTR ($fip.dns_ptr) for ($created.ip) ..."
|
|
let _action = (hetzner_api_floating_ip_set_rdns $fip_id $created.ip $fip.dns_ptr)
|
|
}
|
|
|
|
let delete_protected = ($fip | get -o protection.delete | default false)
|
|
if $delete_protected {
|
|
print $" enabling delete protection on ($fip.name) ..."
|
|
let _action = (hetzner_api_floating_ip_change_protection $fip_id true)
|
|
}
|
|
|
|
{ id: $fip_id, record: $created }
|
|
}
|
|
|
|
# Persist bootstrap resource IDs to .provisioning-state.json in the workspace root.
|
|
def bootstrap-persist-state [ws_root: string, state: record]: nothing -> nothing {
|
|
let state_path = ($ws_root | path join ".provisioning-state.json")
|
|
let existing = if ($state_path | path exists) {
|
|
open --raw $state_path | from json
|
|
} else {
|
|
{}
|
|
}
|
|
($existing | merge $state) | to json --indent 2 | save --force $state_path
|
|
print $" state written to .provisioning-state.json"
|
|
}
|
|
|
|
# Provision L1 Hetzner resources: private network, SSH key, firewall, Floating IPs.
|
|
#
|
|
# Reads infra/bootstrap.ncl from the workspace root. All operations are idempotent —
|
|
# existing resources are detected via API list calls and skipped. Resource IDs are
|
|
# persisted to .provisioning-state.json for use by downstream L2 provisioning.
|
|
export def "main bootstrap" [
|
|
--workspace (-w): string # Workspace name (default: active workspace)
|
|
--dry-run (-n) # Print what would be created without calling the API
|
|
] : nothing -> nothing {
|
|
# Resolve workspace: explicit flag > PWD config/provisioning.ncl > convention > active
|
|
let ws_name = if ($workspace | is-not-empty) {
|
|
$workspace
|
|
} else {
|
|
# Priority 1: config/provisioning.ncl in PWD (workspace root detection)
|
|
let pwd_config = ($env.PWD | path join "config" "provisioning.ncl")
|
|
let from_pwd = if ($pwd_config | path exists) {
|
|
let cfg = (ncl-eval-soft $pwd_config [] null)
|
|
if $cfg != null { $cfg | get -o workspace | default "" } else { "" }
|
|
} else { "" }
|
|
|
|
if ($from_pwd | is-not-empty) {
|
|
$from_pwd
|
|
} else {
|
|
# Priority 2: convention — directory name = workspace name
|
|
let convention = ($env.PWD | path basename)
|
|
let convention_bootstrap = ($env.PWD | path join "infra" "bootstrap.ncl")
|
|
if ($convention_bootstrap | path exists) {
|
|
$convention
|
|
} else {
|
|
# Priority 3: active workspace
|
|
let details = (get-active-workspace-details)
|
|
if ($details == null) {
|
|
error make { msg: "No active workspace. Use --workspace or run from a workspace directory." }
|
|
}
|
|
$details.name
|
|
}
|
|
}
|
|
}
|
|
|
|
# Resolve workspace root: registered path > PWD (when inferred from PWD)
|
|
let ws_root_registered = do -i { get-workspace-path $ws_name } | default ""
|
|
let ws_root = if ($ws_root_registered | is-not-empty) {
|
|
$ws_root_registered
|
|
} else {
|
|
# If not registered, we must be in the workspace root (PWD detection above)
|
|
$env.PWD
|
|
}
|
|
let bootstrap_path = ($ws_root | path join "infra/bootstrap.ncl")
|
|
|
|
if (($bootstrap_path | path exists) == false) {
|
|
error make { msg: $"infra/bootstrap.ncl not found in workspace ($ws_name) at ($ws_root)" }
|
|
}
|
|
|
|
print $"Bootstrap L1 resources for workspace: ($ws_name)"
|
|
print $" config: ($bootstrap_path)"
|
|
|
|
let cfg = (bootstrap-ncl-export $ws_root "infra/bootstrap.ncl")
|
|
|
|
# Support both singular `network` and plural `networks` in bootstrap.ncl.
|
|
let all_networks = if ("networks" in ($cfg | columns)) {
|
|
$cfg.networks
|
|
} else {
|
|
[$cfg.network]
|
|
}
|
|
|
|
if $dry_run {
|
|
print "DRY RUN — resources that would be created:"
|
|
for net in $all_networks {
|
|
print $" network: ($net.name) \(($net.ip_range)\)"
|
|
for sn in ($net.subnets? | default []) {
|
|
print $" subnet: ($sn.ip_range) \(($sn.type), ($sn.network_zone)\)"
|
|
}
|
|
}
|
|
print $" ssh_key: ($cfg.ssh_key.name)"
|
|
print $" firewall: ($cfg.firewall.name)"
|
|
for rule in $cfg.firewall.rules {
|
|
let port_str = if ($rule.port | is-empty) or ($rule.port == null) { "any" } else { $rule.port }
|
|
let src = ($rule.source_ips | str join ", ")
|
|
print $" ($rule.direction) ($rule.protocol)/($port_str) ← ($src)"
|
|
}
|
|
for fip in $cfg.floating_ips {
|
|
print $" floating_ip: ($fip.name) \(($fip.type), ($fip.home_location)\)"
|
|
}
|
|
return
|
|
}
|
|
|
|
print "\n[networks]"
|
|
let network_results = ($all_networks | each { |net| bootstrap-network $net })
|
|
# Primary network is the first one (used for state persistence)
|
|
let network = ($network_results | first)
|
|
|
|
print "\n[ssh_key]"
|
|
let ssh_key = (bootstrap-ssh-key $cfg.ssh_key)
|
|
|
|
print "\n[firewall]"
|
|
let firewall = (bootstrap-firewall $cfg.firewall)
|
|
|
|
print "\n[floating_ips]"
|
|
let fip_results = ($cfg.floating_ips | each {|fip| bootstrap-floating-ip $fip })
|
|
|
|
let fip_state = ($fip_results | reduce --fold {} {|entry, acc|
|
|
let key = ($entry.record.name | str replace --all "librecloud-fip-" "" | str replace --all "-" "_")
|
|
$acc | insert $key { id: $entry.id, ip: $entry.record.ip, name: $entry.record.name }
|
|
})
|
|
|
|
bootstrap-persist-state $ws_root {
|
|
bootstrap: {
|
|
network_id: ($network.id | into string),
|
|
network_name: $network.name,
|
|
ssh_key_id: ($ssh_key.id | into string),
|
|
firewall_id: ($firewall.id | into string),
|
|
floating_ips: $fip_state,
|
|
}
|
|
}
|
|
|
|
# Trigger reconcile so SurrealDB resource records reflect the just-bootstrapped state.
|
|
# Best-effort: silently skipped if the orchestrator daemon is not running.
|
|
let orchestrator_url = ($env.ORCHESTRATOR_URL? | default "http://localhost:8080")
|
|
do -i { http post $"($orchestrator_url)/api/v1/infra/reconcile" {workspace: $ws_name} | ignore }
|
|
|
|
print "\nBootstrap complete."
|
|
print $" network: ($network.name) id=($network.id) range=($cfg.network.ip_range)"
|
|
for sn in $cfg.network.subnets {
|
|
print $" subnet: ($sn.ip_range) \(($sn.type), ($sn.network_zone)\)"
|
|
}
|
|
print $" firewall: ($firewall.name) id=($firewall.id) rules=($cfg.firewall.rules | length)"
|
|
for fip in $fip_results {
|
|
print $" fip ($fip.record.name): id=($fip.id) ip=($fip.record.ip)"
|
|
}
|
|
}
|