provisioning-core/nulib/orchestration/servers/create.nu

1245 lines
55 KiB
Text
Raw Normal View History

use std
# REMOVED: use lib_provisioning * - causes circular import
use utils.nu *
use orchestration/images/state.nu *
use delete.nu [sync-servers-state-post-op]
#use utils.nu on_server_template
use ssh.nu *
use domain/ssh/lib.nu *
# Provider middleware now available through lib_provisioning
use platform/plugins/auth.nu *
use primitives/io/hints.nu *
use primitives/infra/init.nu *
use primitives/io/logging.nu *
use tools/compression.nu *
use platform/service_manager.nu [load-service-config get-service-port]
use ../../../../catalog/providers/prov_lib/middleware.nu [mw_enrich_template_context]
use primitives/io/undefined.nu [invalid_task]
use tools/nickel/process.nu [ncl-eval-soft]
use platform/settings/loader.nu *
use primitives/io/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 server_location = ($server.location? | default "nbg1")
let declared = ($server.volumes? | default []
| each {|v| if "location" in $v { $v } else { $v | insert location $server_location } })
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 --fresh)
# 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 --fresh)
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 = if ($curr_settings.src_path? | is-not-empty) {
$curr_settings.src_path | path expand | path dirname | path dirname
} else { "" }
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 primitives/io/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: "nu_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.
# volumes_only servers also go through render — hetzner_servers.j2 skips creation
# when the server exists; hetzner_volumes.j2 creates/attaches the pending volume.
let to_render_all = ($to_create_annotated | append $to_create_vols_annotated)
let rendered = ($to_render_all | 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 "catalog" | 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 "catalog" | 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
}