prvng_core/nulib/main_provisioning/bootstrap.nu
Jesús Pérez 894046ef5a
feat(core): three-layer DAG, unified component arch, commands-registry cache, Nushell 0.112.2 migration
- 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.
2026-04-17 04:27:33 +01:00

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)"
}
}