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
1239 lines
55 KiB
Text
1239 lines
55 KiB
Text
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
|
||
}
|