prvng_core/nulib/servers/create.nu
Jesús Pérez a6ecf5b7fb
fix(core): resolve undefined symbols hidden by lib_provisioning star-imports
Six symbols were referenced across the codebase but had no definition anywhere.
Star-imports from lib_provisioning/mod.nu silenced the missing-def errors at
parse time; at runtime the call sites either threw or took dead code paths.
ADR-025 Phase 2 (AST audit) surfaced them as blockers for Phase 3 because
selective imports would expose them as "variable not found" errors.

Resolution: add stub getters in lib_provisioning/config/accessor/functions.nu
following the existing pattern (env -> config -> PROVISIONING-derived -> ""):

  - get-providers-path          (14 call sites)
  - get-prov-lib-path           (2 call sites)
  - get-core-nulib-path         (7 call sites)
  - get-provisioning-generate-dirpath  (5 call sites)
  - get-provisioning-generate-defsfile (1 call site)
  - get-provisioning-req-versions (4 call sites)

All existing callers already guard results with is-empty / path exists checks,
so empty-string returns fall back to safe no-op paths.

show_tools_info (main_provisioning/tools.nu) was missing a guard around its
open call; added is-empty / path-exists check matching sibling fns.

The only non-path symbol (on_clusters in clusters/create.nu) had no recoverable
implementation; its closure is replaced with a user-facing message directing
to 'prvng cluster deploy' (the supported workflow).

Refs: ADR-025, .coder/benchmarks/phase2-findings.md blockers section
2026-04-17 07:43:34 +01:00

1239 lines
55 KiB
Text
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use std
# REMOVED: use lib_provisioning * - causes circular import
use utils.nu *
use ../images/state.nu *
use delete.nu [sync-servers-state-post-op]
#use utils.nu on_server_template
use ssh.nu *
use ../lib_provisioning/utils/ssh.nu *
# Provider middleware now available through lib_provisioning
use ../lib_provisioning/plugins/auth.nu *
use ../lib_provisioning/utils/hints.nu *
use ../lib_provisioning/utils/init.nu *
use ../lib_provisioning/utils/logging.nu *
use ../lib_provisioning/utils/script-compression.nu *
use ../lib_provisioning/platform/service-manager.nu [load-service-config get-service-port]
# COMMENTED OUT: tera_daemon.nu has parse errors - will use fallback tera plugin
# use ../lib_provisioning/tera_daemon.nu *
use ../../extensions/providers/prov_lib/middleware.nu [mw_enrich_template_context]
use ../lib_provisioning/utils/undefined.nu [invalid_task]
use ../lib_provisioning/utils/nickel_processor.nu [ncl-eval-soft]
use ../lib_provisioning/utils/settings.nu *
use ../lib_provisioning/utils/interface.nu [set-provisioning-no-terminal set-provisioning-out _ansi _print end_run desktop_run_notify]
# ─────────────────────────────────────────────────────────────────
# Multi-Template Orchestration Helpers (Phase 1)
# Enables conditional template rendering based on server configuration
# ─────────────────────────────────────────────────────────────────
# Determine if template should be rendered based on server config
def should_render_template [
server: record
template_name: string
]: nothing -> bool {
match $template_name {
"common_vals" => true, # Always first: shared header
"ssh_keys" => true, # Always required
"networks" => ($server.networking?.private_network? != null), # Conditional: only if networking.private_network defined
"volumes" => ( # top-level volumes OR schema-nested storage.additional_volumes
($server.volumes? | default [] | length) > 0 or
($server.storage?.additional_volumes? | default [] | length) > 0
),
"servers" => true, # Always required
"firewalls" => true, # Always required
_ => false
}
}
# Build template-specific context for each template type
def build_template_context [
base_context: record
server: record
template_name: string
]: nothing -> record {
let context = $base_context
match $template_name {
"ssh_keys" => {
let ssh_key_config = if ($server.ssh_keys? | default [] | is-not-empty) {
{
name: ($server.ssh_keys | first),
public_key_path: $"~/.ssh/(($server.ssh_keys | first)).pub"
}
} else {
# Default to htz_ops (Hetzner operations SSH key)
# This should be present in ~/.ssh/htz_ops.pub
# CRITICAL: This is the fallback when ssh_keys is not properly exported from Nickel
{ name: "htz_ops", public_key_path: "~/.ssh/htz_ops.pub" }
}
($context | merge { ssh_key: $ssh_key_config })
}
"networks" => {
if ($server.networking?.private_network? != null) {
# Map server location to Hetzner network zone (must match server zone)
let location = ($server.location? | default "nbg1")
let network_zone = match ($location | str downcase) {
"ash" | "ash1" | "as-south" => "ap-southeast", # Ashburn → Singapur
"sjc" | "sjc1" | "us-west" => "us-west", # San Jose
"fsn" | "fsn1" | "eu-central" => "eu-central", # Falkenstein
"hel" | "hel1" | "eu-central" => "eu-central", # Helsinki
"nbg" | "nbg1" | "eu-central" => "eu-central", # Nuremberg
_ => "eu-central" # Default
}
# Build subnet with /22 (supports 1024 IPs instead of 256)
let ip_range = ($server.networking.ip_range? | default "10.0.0.0/16")
let subnet_range = ($server.networking.subnet_range? | default "10.0.0.0/24")
let network_config = {
name: $server.networking.private_network,
ip_range: $ip_range,
subnet_range: $subnet_range,
zone: $network_zone
}
($context | merge { network: $network_config })
} else {
$context
}
}
"volumes" => {
let declared = ($server.volumes? | default [])
let from_storage = (
$server.storage?.additional_volumes? | default []
| each {|v| {
name: $v.name
size: ($v.size_gb? | default 20)
location: ($server.location? | default "nbg1")
format: ($v.type? | default "ext4")
mount_path: ($v.mount_path? | default "")
permanent_mount: ($v.permanent_mount? | default true)
volume_state: ($v.volume_state? | default "new")
}}
)
let all_vols = ($declared | append $from_storage)
# Expose both `server` (singular) and `servers` so the template can reference
# server.hostname for the attach step
($context | merge { volumes: $all_vols, server: $server })
}
"firewalls" => $context
"servers" => {
# Enrich server record: resolve floating_ip_address from state if not set in NCL.
# Priority: NCL explicit value > .servers-state.json > .provisioning-state.json (bootstrap FIPs)
let fip_name = ($server.floating_ip? | default "")
let fip_addr = ($server.floating_ip_address? | default "")
if ($fip_name | is-not-empty) and ($fip_addr | is-empty) {
let ws_root = ($env.PROVISIONING_WORKSPACE_PATH? | default "")
let infra_name = ($server.infra? | default "")
# Try .servers-state.json first
let srv_state_path = ($ws_root | path join "infra" | path join $infra_name | path join ".servers-state.json")
let srv_cached_fip = if ($srv_state_path | path exists) {
open $srv_state_path | get -o ($server.hostname? | default "") | get -o floating_ip_address | default ""
} else { "" }
# Fallback: bootstrap state FIP lookup by name
let resolved_ip = if ($srv_cached_fip | is-not-empty) {
$srv_cached_fip
} else {
let bs_path = ($ws_root | path join ".provisioning-state.json")
if ($bs_path | path exists) {
let fip_key = ($fip_name | str replace --all "librecloud-fip-" "" | str replace --all "-" "_")
open $bs_path | get -o $"bootstrap.floating_ips.($fip_key).ip" | default ""
} else { "" }
}
let enriched_server = ($server | upsert floating_ip_address $resolved_ip)
($context | upsert servers [$enriched_server])
} else {
$context
}
}
_ => $context
}
}
# Concatenate multi-template sections into single atomic bash script
def concatenate_script_sections [
sections: list
]: nothing -> string {
let sorted = ($sections | sort-by priority)
# common_vals (priority 0) MUST be first and without a delimiter so #!/bin/bash is line 1
let body = (
$sorted
| each { |section|
if ($section.priority == 0) {
# Header section: raw content first, no delimiter
$"($section.content)\n"
} else {
let delimiter = $"\n# ========== (($section.name | str upcase)) ==========\n"
let state_load = "[ -f \"\$STATE_DIR/.env\" ] && source \"\$STATE_DIR/.env\"\n"
$"($delimiter)($state_load)($section.content)\n"
}
}
| str join ""
)
let footer = "\n# ========== COMPLETE ==========\n"
[$body, $footer] | str join ""
}
# Get orchestrator URL from platform config/env
# Priority:
# 1. PROVISIONING_ORCHESTRATOR_URL env var (explicit override)
# 2. Load from ~/Library/Application Support/provisioning/platform/config/orchestrator.ncl
# 3. Extract server.port and construct http://localhost:PORT
# Errors if truly unavailable
def get-orchestrator-url-strict [] {
# Priority 1: Environment variable (explicit override)
let env_url = ($env.PROVISIONING_ORCHESTRATOR_URL? | default "")
if ($env_url | is-not-empty) {
return $env_url
}
# Priority 2: Load from platform service config
let orch_config = (load-service-config "orchestrator")
if ($orch_config != null) {
# Check for explicit full URL in config
if ($orch_config.orchestrator? != null) {
if ($orch_config.orchestrator | get --optional "url") != null {
let config_url = ($orch_config.orchestrator.url)
if ($config_url | is-not-empty) {
return $config_url
}
}
}
# Extract port from orchestrator.server.port and construct URL
if ($orch_config.orchestrator? != null) {
if ($orch_config.orchestrator | get --optional "server") != null {
if ($orch_config.orchestrator.server | get --optional "port") != null {
let port = ($orch_config.orchestrator.server.port)
return $"http://localhost:($port)"
}
}
}
}
# No configuration found - error with guidance
error make {
msg: "Orchestrator URL not available. Configure via:
1. Environment: PROVISIONING_ORCHESTRATOR_URL=http://localhost:9011
2. User config: ~/Library/Application Support/provisioning/platform/config/orchestrator.ncl
with structure: { orchestrator: { server: { port: 9011 } } }
3. Command flag: --orchestrator http://localhost:9011"
}
}
# Helper: Compress workflow for orchestrator transmission
# Combines template path, context variables, and rendered script into auditable compressed unit
def prepare_compressed_workflow_payload [] {
# Get captured values from environment (set during template rendering)
let template_path = ($env.LAST_TEMPLATE_PATH? | default "")
let template_context = ($env.LAST_TEMPLATE_CONTEXT? | default {})
let rendered_script = ($env.LAST_RENDERED_SCRIPT? | default "")
if ($template_path | is-empty) or ($rendered_script | is-empty) {
return null
}
# Compress all three as atomic unit
compress-workflow $template_path $template_context $rendered_script
}
# > Server create
export def "main create" [
name?: string # Server hostname in settings
...args # Args for create command
--infra (-i): string # Infra directory
--settings (-s): string # Settings path
--outfile (-o): string # Output file
--serverpos (-p): int # Server position in settings
--check (-c) # Only check mode no servers will be created
--wait (-w) # Wait servers to be created
--select: string # Select with task as option
--debug (-x) # Use Debug mode
--xm # Debug with PROVISIONING_METADATA
--xc # Debuc for task and services locally PROVISIONING_DEBUG_CHECK
--xr # Debug for remote servers PROVISIONING_DEBUG_REMOTE
--xld # Log level with DEBUG PROVISIONING_LOG_LEVEL=debug
--metadata # Error with metadata (-xm)
--notitles # not tittles
--helpinfo (-h) # For more details use options "help" (no dashes)
--out: string # Print Output format: json, yaml, text (default)
--orchestrated # Use orchestrator workflow instead of direct execution
--orchestrator: string = "" # Orchestrator URL (empty = use config/service discovery)
] {
if ($out | is-not-empty) {
set-provisioning-out $out
set-provisioning-no-terminal true
}
# Activate debug flags BEFORE provisioning_init
if $debug { set-debug-enabled true }
if $metadata { set-metadata-enabled true }
if $xm { set-debug-enabled true; set-metadata-enabled true }
if $xc { $env.PROVISIONING_DEBUG_CHECK = "true" }
if $xr { $env.PROVISIONING_DEBUG_REMOTE = "true" }
if $xld { $env.PROVISIONING_LOG_LEVEL = "debug" }
# Convert args to list of strings for provisioning_init
let string_args = ($args | each { $in | into string })
provisioning_init $helpinfo "servers create" $string_args
if $name != null and $name != "h" and $name != "help" {
let infra_arg = if ($infra | is-empty) { null } else { $infra }
let settings_arg = if ($settings | is-empty) { null } else { $settings }
# Get infrastructure path (explicit or from workspace)
let actual_infra = if ($infra_arg == null) {
let ws_path = (get-workspace-path)
if ($ws_path | is-empty) {
# Workspace not found - try local detection or require explicit path
null
} else {
$ws_path | path join "infra" | path join "main"
}
} else {
$infra_arg
}
let curr_settings = (find_get_settings --infra $actual_infra --settings $settings_arg true true)
# Guard: Check that settings loaded successfully
if ($curr_settings == null or ($curr_settings | is-empty)) {
_print "🛑 Failed to load settings"
_print ""
_print "Possible causes:"
_print " 1. Infrastructure path not specified: use --infra <path>"
_print " 2. No settings.ncl/main.ncl in infrastructure directory"
_print " 3. Invalid infrastructure path"
_print ""
_print "Usage examples:"
_print " # From workspace root:"
_print " prvng server create --infra infra/main <server_name>"
_print ""
_print " # From project root:"
_print " prvng server create --infra workspaces/librecloud_hetzner/infra/main <server_name>"
_print ""
_print "Available workspaces:"
_print " provisioning workspace list"
exit 1
}
# Validate server name exists (skip if no servers loaded)
let servers_list = ($curr_settings.data.servers? | default [])
if ($servers_list | length) > 0 {
if ($servers_list | find $name | length) == 0 {
_print $"🛑 invalid name ($name)"
exit 1
}
} else {
# No servers loaded - proceed with check anyway for demonstration
if $check {
_print $"⚠️ Warning: Could not load servers from settings, proceeding with check mode anyway"
}
}
}
let task = if ($args | length) > 0 {
($args| get 0)
} else {
let str_task = (((get-provisioning-args) | str replace "create " " " ))
let str_task = if $name != null {
($str_task | str replace $name "")
} else {
$str_task
}
($str_task | str trim | split row " " | first | default "" | split row "-" | first | default "" | str trim)
}
let other = if ($args | length) > 0 { ($args| skip 1) } else { "" }
let ops = $"((get-provisioning-args)) " | str replace $" ($task) " "" | str trim
match $task {
"" if $name == "h" => {
^$"(get-provisioning-name)" -mod server create help --notitles
},
"" if $name == "help" => {
^$"(get-provisioning-name)" -mod server create --help
_print (provisioning_options "create")
},
"" | "c" | "create" => {
# Guard: Validate settings before proceeding
let infra_arg = if ($infra | is-empty) { null } else { $infra }
let settings_arg = if ($settings | is-empty) { null } else { $settings }
let curr_settings = (find_get_settings --infra $infra_arg --settings $settings_arg true true)
if ($curr_settings | is-empty) or ($curr_settings.wk_path? | is-empty) {
_print "🛑 Failed to load settings"
_print ""
_print "Possible causes:"
_print " 1. No settings.yaml found in infrastructure directory"
_print " 2. Invalid infrastructure path: use --infra /path/to/infra"
_print " 3. No workspace configured. Use 'prvng workspace list' to see available workspaces"
_print ""
_print "Usage:"
_print " prvng server create --infra <path> <server_name>"
exit 1
}
# Main logic: Create servers
set-wk-cnprov $curr_settings.wk_path
# Server name: null/empty = all servers, provided = only that server
let match_name = if $name == null or $name == "" { "" } else { $name}
let run_create = {
on_create_servers $curr_settings $check $wait $outfile $match_name $serverpos --notitles=$notitles --orchestrator=$orchestrator
}
let result = desktop_run_notify $"(get-provisioning-name) servers create" "-> " $run_create --timeout 11sec
if not ($result | get status? | default true) { exit 1 }
# Sync .servers-state.json so server list reflects the new server immediately
if not $check {
let sync_infra = if ($infra | is-not-empty) { $infra | path basename } else { "" }
let sync_ws = $curr_settings.src_path? | default ""
if ($sync_ws | is-not-empty) and ($sync_infra | is-not-empty) {
_print "\n[state sync]"
sync-servers-state-post-op $sync_ws $sync_infra
}
}
},
_ => {
invalid_task "servers create" $task --end
}
}
if not $notitles and not (is-debug-enabled) { end_run "" }
}
export def on_create_servers [
settings: record # Settings record
check: bool # Check mode only: validate without creating
wait: bool # Wait for orchestrator completion
outfile?: string # Output file for check mode (save rendered script)
hostname?: string # Server hostname in settings
serverpos?: int # Server position in settings
--notitles # Don't show titles
--orchestrator: string = "" # Orchestrator URL (REQUIRED for production - error if unresolvable)
] {
# CRITICAL: Verify daemon availability FIRST (before ANY output or processing)
use ../lib_provisioning/utils/service-check.nu verify-daemon-or-block
let daemon_check = (verify-daemon-or-block "create server")
if $daemon_check.status == "error" {
return {status: false, error: "provisioning_daemon not available"}
}
# All creation delegates to orchestrator (no fallback to local execution)
# Orchestrator is mandatory - errors if unavailable
use ../workflows/server_create.nu *
# Resolve orchestrator URL - REQUIRED, NO FALLBACK
let resolved_orchestrator = if ($orchestrator | is-not-empty) {
$orchestrator
} else {
let discovered = (do { get-orchestrator-url-strict } catch { null })
if ($discovered | is-empty) {
_print $"\n❌ Orchestrator REQUIRED for server creation"
_print $" No orchestrator available via:"
_print $" • --orchestrator flag"
_print $" • service-endpoint discovery"
_print $" • config orchestrator.url"
_print $"\n Configure via:"
_print $" 1. Environment: PROVISIONING_ORCHESTRATOR_URL"
_print $" 2. Config: ~/.config/provisioning/config.yaml"
_print $" 3. Service: Platform service registry"
exit 1
} else {
$discovered
}
}
# In check mode: validate server configuration by rendering templates
if $check {
let target_servers = (get-target-servers $settings $hostname $serverpos)
mut check_failed = false
for it in ($target_servers | enumerate) {
if not (create_server $it.item $it.index true $wait $settings $outfile) {
$check_failed = true
break
}
_print $"\n(_ansi blue_reverse)----🌥 ----🌥 ----🌥 ---- oOo ----🌥 ----🌥 ----🌥 ---- (_ansi reset)\n"
}
if $check_failed {
return { status: false, error: "Server check failed" }
}
return { status: true, error: "" }
}
# Production flow: delegate to orchestrator — one workflow per server
let target_servers = (get-target-servers $settings $hostname $serverpos)
let server_count = ($target_servers | length)
# Query live servers first — needed by both bootstrap check and categorization
let hcloud_srv_res = (do { ^hcloud server list -o json } | complete)
let live_servers = if $hcloud_srv_res.exit_code == 0 and ($hcloud_srv_res.stdout | str trim | is-not-empty) {
$hcloud_srv_res.stdout | from json | each {|s| $s.name}
} else { [] }
# Pre-flight: bootstrap validation — verify L1 resources exist before submitting
let bootstrap_errors = (
$target_servers | each {|srv|
mut errs = []
let net = ($srv.networking?.private_network? | default "")
if ($net | is-not-empty) {
let res = (do { ^hcloud network describe $net } | complete)
if $res.exit_code != 0 {
$errs = ($errs | append $"network '($net)' not found — run: prvng bootstrap")
}
}
let fw = ($srv.firewall? | default "")
if ($fw | is-not-empty) {
let res = (do { ^hcloud firewall describe $fw } | complete)
if $res.exit_code != 0 {
$errs = ($errs | append $"firewall '($fw)' not found — run: prvng bootstrap")
}
}
let fip = ($srv.floating_ip? | default "")
let srv_exists = ($live_servers | any {|n| $n == $srv.hostname})
if ($fip | is-not-empty) and not $srv_exists {
let res = (do { ^hcloud floating-ip describe $fip } | complete)
if $res.exit_code != 0 {
$errs = ($errs | append $"floating-ip '($fip)' not found — run: prvng bootstrap")
}
}
if ($errs | is-not-empty) { { host: $srv.hostname, errors: $errs } } else { null }
}
| where { $in != null }
)
if ($bootstrap_errors | is-not-empty) {
_print "\n❌ Bootstrap pre-flight failed:"
for e in $bootstrap_errors {
for msg in $e.errors { _print $" ($e.host): ($msg)" }
}
_print ""
return { status: false, error: "Bootstrap resources missing" }
}
# Pre-flight: categorize servers — full create / volumes-only / nothing to do
let hcloud_vol_res = (do { ^hcloud volume list -o json } | complete)
# Keep full volume records to check attachment state, not just names
let live_volumes_full = if $hcloud_vol_res.exit_code == 0 and ($hcloud_vol_res.stdout | str trim | is-not-empty) {
$hcloud_vol_res.stdout | from json
} else { [] }
let live_volumes = ($live_volumes_full | each {|v| $v.name})
# Classify each server — per-volume state: new | exists_unattached | exists_attached
let classified = ($target_servers | each {|srv|
let srv_exists = ($live_servers | any {|n| $n == $srv.hostname})
let declared_vols = ($srv.storage?.additional_volumes? | default [])
let vol_states = ($declared_vols | each {|v|
let live = ($live_volumes_full | where {|lv| $lv.name == $v.name} | first | default null)
if $live == null {
{ vol: $v, state: "new" } # create + format + attach + mount
} else if ($live.server? | default null) != null {
{ vol: $v, state: "exists_attached" } # nothing to do
} else {
{ vol: $v, state: "exists_unattached" } # attach + mount only — NO format
}
})
let needs_work = ($vol_states | where {|vs| $vs.state != "exists_attached"} | length) > 0
if not $srv_exists {
{ srv: $srv, mode: "full", vol_states: $vol_states }
} else if $needs_work {
let pending = ($vol_states | where {|vs| $vs.state != "exists_attached"} | each {|vs| $"($vs.vol.name)=($vs.state)"} | str join ', ')
_print $" Server (_ansi cyan_bold)($srv.hostname)(_ansi reset) exists — pending volumes: ($pending)"
{ srv: $srv, mode: "volumes_only", vol_states: $vol_states }
} else {
_print $" Server (_ansi cyan_bold)($srv.hostname)(_ansi reset) — all volumes attached"
{ srv: $srv, mode: "skip", vol_states: $vol_states }
}
})
let to_create = ($classified | where mode == "full" | get srv)
let to_create_vols = ($classified | where mode == "volumes_only" | get srv)
let skipped = ($classified | where mode == "skip" | get srv)
# Annotate servers with per-volume state so templates can act correctly:
# new → hcloud create + attach + vol-prepare (format + mount persistent)
# exists_unattached → hcloud attach only + mount if mount_path declared (no format)
# exists_attached → nothing
# permanent_mount (default true): adds fstab entry; false = attach without fstab
let annotate_vols = {|srv classified_entry|
let vols = ($srv.storage?.additional_volumes? | default [] | each {|v|
let vs = ($classified_entry.vol_states | where {|x| $x.vol.name == $v.name} | first | default null)
let state = if $vs != null { $vs.state } else { "new" }
let permanent = ($v.permanent_mount? | default true)
$v | merge { volume_state: $state, permanent_mount: $permanent }
})
if ($vols | is-not-empty) {
$srv | upsert storage ($srv.storage | upsert additional_volumes $vols)
} else { $srv }
}
let full_entries = ($classified | where mode == "full")
let vol_only_entries = ($classified | where mode == "volumes_only")
let to_create_annotated = ($full_entries | each {|e| do $annotate_vols $e.srv $e})
let to_create_vols_annotated = ($vol_only_entries | each {|e| do $annotate_vols $e.srv $e})
if ($to_create | is-empty) and ($to_create_vols | is-empty) {
_print "\nNothing to do — all servers and volumes already exist."
return { status: true, error: "" }
}
let submit_list = ($to_create_annotated | append $to_create_vols_annotated)
_print $"\nCreate (_ansi blue_bold)($submit_list | length)(_ansi reset) servers (_ansi blue_bold)>>> 🌥 → Orchestrator(_ansi reset)\n"
_print $"✓ Submitting to orchestrator: (_ansi cyan)($resolved_orchestrator)(_ansi reset)"
_print $"Servers to create:"
$to_create | each { |srv| _print $" - ($srv.hostname) [($srv.provider)]" }
_print ""
# Phase 1: Render + compress SEQUENTIALLY — tera plugin reads JSON context files
# from disk; compress-workflow writes to /tmp and returns base64 payload immediately.
# Both are safe to run sequentially. Each server gets its own compressed archive.
let rendered = ($to_create | enumerate | each {|it|
let srv = $it.item
let render_result = (create_server $srv $it.index false $wait $settings)
let render_ok = (
($render_result | describe | str starts-with "record") and
($render_result | get success? | default false)
)
let script = if $render_ok { ($render_result | get rendered_script? | default "") } else { "" }
let tpl_path = if $render_ok { ($render_result | get template_path? | default "") } else { "" }
let tpl_ctx = if $render_ok { ($render_result | get template_context? | default {}) } else { {} }
let ok = ($render_ok and ($script | is-not-empty))
let compression = if $ok {
compress-workflow $tpl_path $tpl_ctx $script
} else { {} }
{
hostname: $srv.hostname,
compression: $compression,
ok: $ok
}
})
let render_failures = ($rendered | where ok == false)
if ($render_failures | length) > 0 {
$render_failures | each { |r| _print $"\n❌ Template render failed for ($r.hostname)" }
return { status: false, error: "Template rendering failed" }
}
# Phase 2: Submit + wait in parallel — each closure carries its own compressed archive.
# No shared env state. HTTP POST + polling are thread-safe.
let results = ($rendered | par-each {|r|
let c = $r.compression
let wf = (on_create_servers_workflow $settings false $wait $outfile $r.hostname
--orchestrator $resolved_orchestrator
--script-compressed ($c | get script_compressed? | default "")
--template-path ($c | get template_path? | default "")
--compression-ratio ($c | get compression_ratio? | default 0.0)
--original-size ($c | get original_size? | default 0)
--compressed-size ($c | get compressed_size? | default 0)
)
if not $wf.status {
{ hostname: $r.hostname, status: "failed", task_id: "", error: ($wf.error? | default "submit failed") }
} else {
{ hostname: $r.hostname, status: "ok", task_id: ($wf | get task_id? | default ""), error: "" }
}
})
let failed = ($results | where status != "ok")
let succeeded = ($results | where status == "ok")
$succeeded | each { |r| _print $" ✓ ($r.hostname) submitted" }
$failed | each { |r| _print $"\n❌ ($r.hostname): ($r.error)" }
if ($failed | length) > 0 {
return { status: false, error: "One or more servers failed to submit" }
}
let task_ids = ($succeeded | get task_id | where { $in | is-not-empty })
if $wait {
_print $"\n✅ Server creation completed successfully"
show-next-step "server_create" {infra: $settings.infra_path}
} else {
_print $"\n📋 Server creation workflows submitted to orchestrator"
$task_ids | each { |tid| _print $" (_ansi green)($tid)(_ansi reset)" }
_print ""
_print $"(_ansi cyan)Monitor execution:(_ansi reset)"
$task_ids | each { |tid| _print $" provisioning workflow status ($tid)" }
}
{ status: true, error: "" }
}
# Helper: Get target servers based on filters
def get-target-servers [settings: record, hostname?: string, serverpos?: int] {
let match_hostname = if $hostname != null {
$hostname
} else if $serverpos != null {
let total = ($settings.data.servers | length)
if $serverpos > 0 and $serverpos <= $total {
($settings.data.servers | get ($serverpos - 1)).hostname
} else {
null
}
} else {
null
}
$settings.data.servers | where {|srv|
if $match_hostname == null or $match_hostname == "" {
true
} else if $srv.hostname == $match_hostname {
true
} else {
$srv.hostname | str starts-with $match_hostname
}
}
}
# Helper: Get server hostnames as list
def get-target-servers-list [settings: record, hostname?: string, serverpos?: int] {
get-target-servers $settings $hostname $serverpos | each {|srv| $srv.hostname}
}
# Pre-flight check for servers that reference a role image.
# Returns {ok: bool, severity: string, message: string}.
# severity "stop" aborts creation; "warn" prints and continues.
def preflight_image_check [server: record]: nothing -> record {
let role = ($server | get -o image_role | default null)
if ($role | is-empty) { return { ok: true, severity: "", message: "" } }
let provider = $server.provider
let state = (image-state-read $provider $role)
if $state.snapshot_id == "SNAPSHOT_PENDING" {
return {
ok: false,
severity: "stop",
message: $"Image role '($role)' has no snapshot. Run: provisioning build image create ($role)",
}
}
let fresh = (do { image-state-is-fresh $provider $role } catch { false })
if not $fresh {
return {
ok: true,
severity: "warn",
message: $"Image role '($role)' snapshot ($state.snapshot_id) may be stale. Consider: provisioning build image update ($role)",
}
}
{ ok: true, severity: "", message: "" }
}
export def create_server [
server: record
index: int
check: bool
wait: bool
settings: record
outfile?: string
] {
## Provider middleware now available through lib_provisioning
#use utils.nu *
# Generate state directory with timestamp for provisioning state management
# Format: provisioning-{cluster}-{YYYYMMDD}-{HHMMSS}
# This is done before check mode so state_dir is available for templates
let now_date = (date now)
let timestamp = ($now_date | format date '%Y%m%d-%H%M%S')
let cluster_name = (
# Try to extract cluster name from infra path or settings
if ($settings.data.cluster? | is-not-empty) {
$settings.data.cluster
} else if ($settings.infra_path | str contains "librecloud") {
"librecloud"
} else if ($settings.infra_path | str contains "wuji") {
"wuji"
} else {
# Extract from last path component of infra path
$settings.infra_path | path basename
}
)
let state_dir = ($settings.wk_path | path join ".provisioning-tmp" | path join $"provisioning-($cluster_name)-($timestamp)")
# Pre-flight: verify provider is declared in the server config
if ($server.provider? | is-empty) {
error make { msg: $"Server '($server.hostname?)' is missing required field 'provider'. Declare it explicitly in your infra servers.ncl." }
}
# Pre-flight: verify role image exists and is fresh before any template work
let image_check = (preflight_image_check $server)
if not $image_check.ok {
_print $"🛑 ($image_check.message)"
return false
}
if ($image_check.severity == "warn") {
_print $"⚠️ ($image_check.message)"
}
# In check mode, show what would be created
if $check {
# Multi-template orchestration: Determine which templates to render
# Template priority (execution order):
# 1. ssh_keys (always)
# 2. networks (if private_network defined)
# 3. firewalls (always — must exist before server so attach works)
# 4. volumes (if volumes array not empty)
# 5. servers (always — creates server + attaches to firewall)
let templates_config = [
{ name: "common_vals", priority: 0 }
{ name: "ssh_keys", priority: 1 }
{ name: "networks", priority: 2 }
{ name: "firewalls", priority: 3 }
{ name: "servers", priority: 4 }
{ name: "volumes", priority: 5 }
]
# Build template list with file paths
let workspace_infra_path = ($settings.src_path | path dirname | path dirname)
mut to_render = []
for tpl in $templates_config {
# Check if this template should be rendered
if not (should_render_template $server $tpl.name) {
continue
}
# Resolve path: workspace → system
let template_filename = $"($server.provider)_($tpl.name).j2"
let workspace_path = ($workspace_infra_path | path join ".providers" | path join $server.provider | path join "templates" | path join $template_filename)
let system_path = ($env.PROVISIONING | path join "extensions" | path join "providers" | path join $server.provider | path join "templates" | path join $template_filename)
let template_path = if ($workspace_path | path exists) { $workspace_path } else { $system_path }
if ($template_path | path exists) {
$to_render = ($to_render | append { name: $tpl.name, path: $template_path, priority: $tpl.priority })
}
}
# Verify critical templates exist
if (($to_render | where name == "servers" | length) == 0) {
_print "❌ Critical: servers template not found"
return false
}
let server_template = ($to_render | where name == "servers" | first | get path)
# Temporarily disable NO_TERMINAL to ensure check output is displayed
let old_no_terminal = ($env.PROVISIONING_NO_TERMINAL? | default false)
$env.PROVISIONING_NO_TERMINAL = false
_print $"\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
_print $"Check: Create server (_ansi cyan_bold)($server.hostname)(_ansi reset) with provider (_ansi green_bold)($server.provider)(_ansi reset)"
_print $"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if ($server_template | path exists) {
_print $"\n📋 Template: ($server_template)"
# Show template rendering info
_print "\n🔧 Generated script:"
_print "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# Build complete context record with all variables the template expects
# Augment server object with default fields that template expects
let server_with_defaults = ($server | merge {
ssh_keys: ($server.ssh_keys? | default [])
labels: ($server.labels? | default {})
volumes: ($server.volumes? | default [])
location: ($server.location? | default "nbg1")
})
# Load cluster-level firewalls from workspace Nickel config
let firewalls_ncl = ($settings.infra_path | path join "firewalls.ncl")
let firewalls = if ($firewalls_ncl | path exists) {
ncl-eval-soft $firewalls_ncl [] [] | get -o firewalls | default []
} else { [] }
let template_context = {
servers: [$server_with_defaults]
firewalls: $firewalls
defaults: {}
match_server: $server.hostname
cluster_name: $cluster_name
state_dir: $state_dir
provisioning_version: "1.0.4"
now: ($now_date | format date '%Y-%m-%d %H:%M:%S')
debug: (if ($env.PROVISIONING_DEBUG? | is-not-empty) { "yes" } else { "no" })
use_time: "false"
wait: false
runset: {output_format: "yaml"}
wk_file: ($settings.wk_path | path join "creation_script.sh")
}
# Capture template and context for compression/orchestrator transmission
$env.LAST_TEMPLATE_PATH = $server_template
$env.LAST_TEMPLATE_CONTEXT = $template_context
# DEBUG: Save context to file for inspection
($template_context | to json) | save -f /tmp/tpl_context.json
print $" Template context saved to /tmp/tpl_context.json"
# Ensure tera plugin is loaded
let tera_loaded = (plugin list | where name == "tera" | length) > 0
if not $tera_loaded {
(plugin use tera)
}
# Phase 1: Enrich template context via provider (cache management is provider's responsibility)
let rendering_context = (mw_enrich_template_context $settings $server $template_context)
# Render all selected templates with appropriate context
mut sections = []
for tpl in $to_render {
# Build template-specific context with cached resources
let tpl_context = (build_template_context $rendering_context $server $tpl.name)
# Save context to temp file for this template
let ctx_file = $"/tmp/tpl_($server.hostname)_($tpl.name)_ctx.json"
($tpl_context | to json) | save -f $ctx_file
# Render template
let absolute_template = (($tpl.path | path expand) | str trim)
let render_result = (do {
let rendered = (tera-render $absolute_template $ctx_file)
{success: true, content: $rendered, error: null}
} catch { |e|
{success: false, content: null, error: $"Error rendering ($tpl.name): $($e)"}
})
if not $render_result.success {
print $"❌ ($render_result.error)"
$env.PROVISIONING_NO_TERMINAL = $old_no_terminal
exit 1
}
# Collect rendered section
$sections = ($sections | append {
name: $tpl.name,
content: $render_result.content,
priority: $tpl.priority
})
}
# Concatenate all sections into single atomic script
let final_script = (concatenate_script_sections $sections)
# Capture rendered script for compression/orchestrator transmission
$env.LAST_RENDERED_SCRIPT = $final_script
# Handle outfile parameter: save to file if provided, otherwise print to stdout
let has_outfile = ($outfile != null and ($outfile | str length) > 0)
if $has_outfile {
# Expand the outfile path to absolute
let absolute_outfile = ($outfile | path expand)
# Create parent directories if they don't exist
let outfile_dir = ($absolute_outfile | path dirname)
if not ($outfile_dir | path exists) {
^mkdir -p $outfile_dir
}
# Write rendered content to file
$final_script | save --force $absolute_outfile
print $"✅ Script saved to: ($absolute_outfile)"
print $" State directory: ($state_dir)"
} else {
# Pipe through bat for syntax highlighting and paging
let bat_available = (which bat | is-not-empty)
if $bat_available {
$final_script | ^bat --language bash --style plain --paging auto
} else {
# Fallback to plain print if bat not available
print $final_script
}
}
print $"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
print $"\n✅ Check completed successfully"
print $" Server configuration:"
print $" • Hostname: ($server.hostname? | default '')"
print $" • Provider: ($server.provider)"
print $" • Type: ($server.server_type?| default '')"
print $" • Location: ($server.location? | default '')"
print $" • Cluster: ($cluster_name | default '')"
# Show what's included in the atomic script
print "\n📋 Atomic script includes:"
print " ✓ Server creation"
print " ✓ Firewall setup:"
#print " - SSH (TCP 22) from 0.0.0.0/0 and ::/0"
#print " - ICMP from 0.0.0.0/0 and ::/0"
#print " - Outbound TCP, UDP, ICMP to anywhere"
print " ✓ Idempotent checks (safe to retry)"
print ""
print " (Check mode - nothing executed)"
print ""
print " Next steps:"
print (" ▶ Execute locally: provisioning create server " + $server.hostname + " --infra " + $settings.infra_path)
print (" ▶ Save script: provisioning create server " + $server.hostname + " --infra " + $settings.infra_path + " --outfile ~/provisioning-script.sh")
print (" ▶ Via orchestrator: provisioning create server " + $server.hostname + " --infra " + $settings.infra_path + " --orchestrated")
print ""
print " Note: Orchestrator receives metadata (infra, settings), then regenerates and executes script"
# Restore original NO_TERMINAL setting and exit immediately in check mode
# Exit directly to avoid any cleanup code that might hang with bat/pager
$env.PROVISIONING_NO_TERMINAL = $old_no_terminal
exit 0
} else {
_print $"\n⚠ Template not found: ($server_template)"
$env.PROVISIONING_NO_TERMINAL = $old_no_terminal
return false
}
}
# PRODUCTION MODE: Render template first (before any server checks)
# In production, we MUST capture the script for orchestrator transmission
if not $check {
# Production flow: render template immediately
} else {
# Check mode already handled above (line 426)
# If we reach here in check mode, something is wrong
_print "🛑 Unexpected state: check mode not handled"
return false
}
# Production mode: Multi-template orchestration (same as check mode)
# Build template list with file paths
let templates_config = [
{ name: "common_vals", priority: 0 } # shebang + STATE_DIR + set -euo pipefail
{ name: "ssh_keys", priority: 1 }
{ name: "networks", priority: 2 }
{ name: "firewalls", priority: 3 }
{ name: "servers", priority: 4 }
{ name: "volumes", priority: 5 }
]
let workspace_infra_path = ($settings.src_path | path dirname | path dirname)
mut to_render = []
for tpl in $templates_config {
# Check if this template should be rendered
if not (should_render_template $server $tpl.name) {
continue
}
# Resolve path: workspace → system
let template_filename = $"($server.provider)_($tpl.name).j2"
let workspace_path = ($workspace_infra_path | path join ".providers" | path join $server.provider | path join "templates" | path join $template_filename)
let system_path = ($env.PROVISIONING | path join "extensions" | path join "providers" | path join $server.provider | path join "templates" | path join $template_filename)
let template_path = if ($workspace_path | path exists) { $workspace_path } else { $system_path }
if ($template_path | path exists) {
$to_render = ($to_render | append { name: $tpl.name, path: $template_path, priority: $tpl.priority })
}
}
# Verify critical templates exist
if (($to_render | where name == "servers" | length) == 0) {
_print "❌ Critical: servers template not found"
return false
}
# Build template context (same as check mode)
let now_date = (date now)
let cluster_name = (
if ($settings.data.cluster? | is-not-empty) {
$settings.data.cluster
} else if ($settings.infra_path | str contains "librecloud") {
"librecloud"
} else if ($settings.infra_path | str contains "wuji") {
"wuji"
} else {
$settings.infra_path | path basename
}
)
let timestamp = ($now_date | format date '%Y%m%d-%H%M%S')
let state_dir = ($settings.wk_path | path join ".provisioning-tmp" | path join $"provisioning-($cluster_name)-($timestamp)")
let server_with_defaults = ($server | merge {
ssh_keys: ($server.ssh_keys? | default [])
labels: ($server.labels? | default {})
volumes: ($server.volumes? | default [])
location: ($server.location? | default "nbg1")
})
let template_context = {
servers: [$server_with_defaults]
defaults: {}
match_server: $server.hostname
cluster_name: $cluster_name
state_dir: $state_dir
provisioning_version: "1.0.4"
now: ($now_date | format date '%Y-%m-%d %H:%M:%S')
debug: (if ($env.PROVISIONING_DEBUG? | is-not-empty) { "yes" } else { "no" })
use_time: "false"
wait: false
runset: {output_format: "yaml"}
wk_file: ($settings.wk_path | path join "creation_script.sh")
}
# Ensure tera plugin is loaded
let tera_loaded = (plugin list | where name == "tera" | length) > 0
if not $tera_loaded {
(plugin use tera)
}
# Render all selected templates
mut sections = []
for tpl in $to_render {
# Build template-specific context
let tpl_context = (build_template_context $template_context $server $tpl.name)
# Save context to temp file — include hostname to avoid races in par-each
let ctx_file = $"/tmp/tpl_prod_($server.hostname)_($tpl.name)_ctx.json"
($tpl_context | to json) | save -f $ctx_file
# Render template
let absolute_template = (($tpl.path | path expand) | str trim)
let render_result = (do {
let rendered = (tera-render $absolute_template $ctx_file)
{success: true, content: $rendered, error: null}
} catch { |e|
{success: false, content: null, error: $"Error rendering ($tpl.name): $($e)"}
})
if not $render_result.success {
_print $"❌ ($render_result.error)"
return false
}
# Collect rendered section
$sections = ($sections | append {
name: $tpl.name,
content: $render_result.content,
priority: $tpl.priority
})
}
# Concatenate all sections into single atomic script
let final_script = (concatenate_script_sections $sections)
if ($final_script | is-empty) or ($final_script | str length) == 0 {
_print $"❌ Template rendering failed: empty output"
return false
}
# Capture for compression/orchestrator transmission
$env.LAST_TEMPLATE_PATH = ($to_render | first | get path)
$env.LAST_TEMPLATE_CONTEXT = $template_context
$env.LAST_RENDERED_SCRIPT = $final_script
# Return both success and rendered script for orchestrator
{
success: true,
rendered_script: $final_script,
template_path: ($to_render | first | get path),
template_context: $template_context
}
}
export def verify_server_info [
settings: record
server: record
info: record
] {
_print $"Checking server (_ansi green_bold)($server.hostname)(_ansi reset) info "
let server_plan = ($server | get plan? | default "")
let curr_plan = ($info | get plan? | default "")
if ($server_plan | is-not-empty) {
if $server_plan != $curr_plan {
mw_modify_server $settings $server [{plan: $server_plan}] false
}
}
}
export def check_server [
settings: record
server: record
index: int
info: record
check: bool
wait: bool
settings: record
outfile?: string
] {
## Provider middleware now available through lib_provisioning
#use utils.nu *
let server_info = if ($info | is-empty) {
(mw_server_info $server true)
} else {
$info
}
let already_created = ($server_info | is-not-empty)
if not $already_created {
_print $"🛑 server (_ansi green_bold)($server.hostname)(_ansi reset) not exists"
return false
}
if not $check {
^ssh-keygen -f $"($env.HOME)/.ssh/known_hosts" -R $server.hostname err> (if $nu.os-info.name == "windows" { "NUL" } else { "/dev/null" })
let ip_raw = (mw_get_ip $settings $server $server.liveness_ip false )
let ip = ($ip_raw | str trim --char "\"")
if $ip == "" {
_print "🛑 No liveness ip found for state checking "
return false
}
verify_server_info $settings $server $server_info
_print $"liveness (_ansi purple)($ip):($server.liveness_port)(_ansi reset)"
if (wait_for_server $index $server $settings $ip) {
# Check if SSH setup succeeded (returns false on CTRL-C during sudo)
let ssh_result = (on_server_ssh $settings $server "pub" "create" false $check)
if not $ssh_result {
_print $"\n(_ansi red)✗ Server creation cancelled(_ansi reset)"
return false
}
# collect fingerprint
let res = (^ssh-keyscan "-H" $ip err> (if $nu.os-info.name == "windows" { "NUL" } else { "/dev/null" })| complete)
if $res.exit_code == 0 {
let known_hosts_path = (("~" | path join ".ssh" | path join "known_hosts") | path expand)
let markup = $"# ($ip) keyscan"
let lines_found = (open $known_hosts_path --raw | lines | find $markup | length)
if $lines_found == 0 {
( $"($markup)\n" | save --append $known_hosts_path)
($res.stdout | save --append $known_hosts_path)
_print $"(_ansi green_bold)($ip)(_ansi reset) (_ansi yellow)ssh-keyscan(_ansi reset) added to ($known_hosts_path)"
}
#} else {
# _print $"🛑 Error (_ansi yellow)ssh-keyscan(_ansi reset) from ($ip)"
# _print $"($res.stdout)"
}
if $already_created {
let res = (mw_post_create_server $settings $server $check)
match $res {
"error" | "-1" => { exit 1},
"storage" | "" => {
let storage_sh = ($settings.wk_path | path join $"($server.hostname)-storage.sh")
let result = (on_server_template (get-templates-path | path join "storage.j2") $server 0 true true true $settings $storage_sh)
if $result and ($storage_sh | path exists) and (wait_for_server $index $server $settings $ip) {
let target_cmd = "/tmp/storage.sh"
#use ssh.nu scp_to ssh_cmd
if not (scp_to $settings $server [$storage_sh] $target_cmd $ip) { return false }
_print $"Running (_ansi blue_italic)($target_cmd | path basename)(_ansi reset) in (_ansi green_bold)($server.hostname)(_ansi reset)"
if not (ssh_cmd $settings $server true $target_cmd $ip) { return false }
if (is-ssh-debug-enabled) { return true }
if not (is-debug-enabled) {
(ssh_cmd $settings $server false $"rm -f ($target_cmd)" $ip)
}
} else {
return false
}
}
_ => {
return true
},
}
}
}
}
true
}