# Image create — render build template, execute, capture snapshot ID, persist state. use ./state.nu * use ../lib_provisioning/utils/nickel_processor.nu [ncl-eval] # Load the ImageRole definition from the workspace images.ncl for a given role name. def load-image-role [infra: string, role: string]: nothing -> record { let images_ncl = ($infra | path join "images.ncl") if not ($images_ncl | path exists) { error make { msg: $"images.ncl not found at ($images_ncl)" } } let data = (ncl-eval $images_ncl []) let roles = ($data | get image_roles? | default {}) let role_def = ($roles | get -o $role) if ($role_def | is-empty) { error make { msg: $"Role '($role)' not defined in ($images_ncl)" } } $role_def } # Build template context and render via tera plugin. def render-build-template [role_def: record, infra: string, check: bool]: nothing -> string { let tera_loaded = (plugin list | where name == "tera" | length) > 0 if not $tera_loaded { plugin use tera } let provider = ($role_def | get provider? | default "hetzner") let tpl_name = ($role_def | get template_name? | default "hetzner_build_image.j2") let tpl_path = ($env.PROVISIONING | path join "extensions" | path join "providers" | path join $provider | path join "templates" | path join $tpl_name) if not ($tpl_path | path exists) { error make { msg: $"Build template not found: ($tpl_path)" } } # Calculate flake directory: go up 2 levels from infra/wuji to workspace root, then add nixos let infra_expanded = ($infra | path expand) let workspace_root = ($infra_expanded | path dirname | path dirname) let flake_dir = ($workspace_root | path join "nixos") let ctx = { image_role: $role_def, ssh_key: ($role_def | get ssh_key? | default ""), location: ($role_def | get location? | default "nbg1"), flake_dir: $flake_dir, now: ((date now) | format date "%Y-%m-%dT%H:%M:%SZ"), provisioning_version: ($env.PROVISIONING_VERSION? | default "0.0.0"), check: $check, } $ctx | tera-render $tpl_path } # Parse the SNAPSHOT_ID= line from build script stdout. def extract-snapshot-id [output: string]: nothing -> string { let line = ($output | lines | find "SNAPSHOT_ID=" | first?) if ($line | is-empty) { error make { msg: "Build script did not emit SNAPSHOT_ID=" } } $line | str replace "SNAPSHOT_ID=" "" | str trim } export def image-create [ role: string --infra: string = "" --check ] { let infra_path = if ($infra | is-empty) { let ws = ($env.PROVISIONING_WORKSPACE? | default "") if ($ws | is-empty) { error make { msg: "Specify --infra or set PROVISIONING_WORKSPACE" } } $ws | path join "infra" } else { let expanded = ($infra | path expand) # Detect if we're in a project subdirectory and path was duplicated # E.g., ran from /project/workspaces with --infra workspaces/... → /project/workspaces/workspaces/... if ($expanded | str contains "workspaces/workspaces") or ($expanded | str contains "infra/infra") { let cwd = (pwd) let infra_parts = ($infra | split row "/") let first_part = ($infra_parts | get 0) # If we're in a subdirectory that matches the first part of --infra, strip it if ($cwd | str contains $first_part) { let adjusted = ($infra_parts | skip 1 | str join "/") let adjusted_path = ($adjusted | path expand) if ($adjusted_path | path exists) { $adjusted_path } else { error make { msg: $"Path duplication detected in: ($expanded)\n\nYou appear to be in a subdirectory. Either:\n 1. Run from project root: cd ($env.HOME)/project-provisioning\n 2. Use absolute path: --infra ($env.HOME)/project-provisioning/workspaces/...\n 3. Use relative from current dir: --infra librecloud_hetzner/infra/wuji" } } } else { $expanded } } else { $expanded } } let role_def = (load-image-role $infra_path $role) let provider = ($role_def | get provider? | default "hetzner") print $"Building image role '($role)' for provider '($provider)'" if $check { let script = (render-build-template $role_def $infra_path true) print "── [check mode] rendered build script ──" print $script print "── no snapshot created ──" return } let script = (render-build-template $role_def $infra_path false) let tmp_dir = ($env.TMPDIR? | default "/tmp") let tmp_path = ($tmp_dir | path join $"build_image_($provider)_($role).sh") $script | save --force $tmp_path ^chmod +x $tmp_path print $"Executing build script: ($tmp_path)" print "" # Execute script - redirect output to log file for visibility let tmp_log = ($tmp_dir | path join $"build_image_($provider)_($role).log") # Run bash script via shell, capturing output to log file # Don't use Nushell's external command error handling - let shell handle it ^sh -c $"bash -x ($tmp_path) >($tmp_log) 2>&1 || true" # ALWAYS print build output, even if bash failed if ($tmp_log | path exists) { print "" print "=== Build Output ===" print (open $tmp_log) print "" } # Check if script had any error (look for error: in output) if ($tmp_log | path exists) { let log_content = (open $tmp_log) if ($log_content | str contains "error:") { print "❌ BUILD FAILED - see output above for details" exit 1 } } let snapshot_id = (extract-snapshot-id (open $tmp_log)) print $"Snapshot created: ($snapshot_id)" let os_base = ($role_def | get os_base? | default "debian-12") let labels = ($role_def | get labels? | default {}) image-state-write $provider $role { provider: $provider, role: $role, snapshot_id: $snapshot_id, built_at: ((date now) | format date "%Y-%m-%dT%H:%M:%SZ"), last_used: null, os_base: $os_base, labels: $labels, } print $"State saved: (image-state-path $provider $role)" }