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 ../lib_provisioning/providers.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 " _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 " _print "" _print " # From project root:" _print " prvng server create --infra workspaces/librecloud_hetzner/infra/main " _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 " 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 }