#!/usr/bin/env nu # Generic component bundle renderer. # # Discovers templates from _renderer/common-templates/ (shared scripts) and # /cluster/templates/ (YAMLs + optional per-component overrides). # Component templates override common by name. # # Variables: # - Auto-flattened scalars from NCL subtree (UPPER_SNAKE_CASE with dotted paths) # - Extras from optional /cluster/vars.nu # - MANIFESTS_ORDER: explicit via vars.nu, or derived from YAML kinds # - BUNDLE_ID, COMPONENT: injected # # Modes: # (default) Write bundle to --out// # --plan Vars + file tree + diff vs prior bundle # --render-stdout All files to stdout # --diff kubectl diff vs live cluster # --vars Just extracted vars (debug) # ─── paths ────────────────────────────────────────────────────────────── def components_dir []: nothing -> path { $env.FILE_PWD | path dirname } def component_manifests_dir [name: string]: nothing -> path { components_dir | path join $name "cluster" "manifests" } def provisioning_root []: nothing -> path { $env.FILE_PWD | path dirname --num-levels 3 } def common_templates_dir []: nothing -> path { $env.FILE_PWD | path join "common-templates" } def component_templates_dir [name: string]: nothing -> path { components_dir | path join $name "cluster" "templates" } def component_vars_script [name: string]: nothing -> path { components_dir | path join $name "cluster" "vars.nu" } # ─── NCL export + component detection ────────────────────────────────── def nickel_export [ncl_path: path]: nothing -> record { let r = ( do { ^nickel export --format json --import-path (provisioning_root) $ncl_path } | complete ) if $r.exit_code != 0 { error make { msg: $"nickel export failed:\n($r.stderr)" } } $r.stdout | from json } def detect_component [exported: record]: nothing -> string { let keys = ($exported | columns) if ($keys | length) != 1 { error make { msg: $"expected single top-level key in NCL export, got: ($keys)" } } $keys | first } # ─── template discovery (common + component) ────────────────────────── def discover_templates [component: string]: nothing -> list> { let cdir = common_templates_dir let pdir = (component_templates_dir $component) let common_list = if ($cdir | path exists) { glob ($cdir | path join "*.tmpl") | each {|p| { name: ($p | path basename | str replace --regex '\.tmpl$' ""), src: $p } } } else { [] } let comp_list = if ($pdir | path exists) { glob ($pdir | path join "*.tmpl") | each {|p| { name: ($p | path basename | str replace --regex '\.tmpl$' ""), src: $p } } } else { [] } let comp_names = ($comp_list | get name) let filtered_common = ($common_list | where {|t| $t.name not-in $comp_names }) $filtered_common ++ $comp_list } # ─── variable extraction ────────────────────────────────────────────── def flatten_scalars [rec: record, prefix: string]: nothing -> record { $rec | transpose key value | reduce --fold {} {|row, acc| let path = if ($prefix | is-empty) { $row.key } else { $"($prefix)_($row.key)" } let t = ($row.value | describe) if ($t | str starts-with "record") { $acc | merge (flatten_scalars $row.value $path) } else if (($t | str starts-with "list") or ($t | str starts-with "table")) { $acc } else if ($row.value == null) { $acc } else { $acc | insert ($path | str upcase) ($row.value | into string) } } } def derive_extra_vars [component_name: string, component_record: record]: nothing -> record { let vars_file = (component_vars_script $component_name) if not ($vars_file | path exists) { return {} } let r = ( do { ^nu $vars_file ($component_record | to json --raw) } | complete ) if $r.exit_code != 0 { error make { msg: $"vars.nu failed for ($component_name):\n($r.stderr)" } } if ($r.stdout | str trim | is-empty) { {} } else { $r.stdout | from json } } # K8s apply precedence: lower first. Ignored kinds default to high value (applied last). const YAML_PRECEDENCE = { namespace: 1 configmap: 2 secret: 2 pv: 3 pvc: 3 statefulset: 4 deployment: 4 daemonset: 4 job: 4 server: 4 agent: 5 service: 6 ingress: 7 } def default_manifests_order [yaml_names: list]: nothing -> string { $yaml_names | sort-by {|n| let stem = ($n | str replace --regex '\.yaml$' "") $YAML_PRECEDENCE | get -o $stem | default 10 } | str join " " } def build_vars [ exported: record component: string bundle_id: string template_names: list ]: nothing -> record { let subtree = ($exported | get $component) let auto = (flatten_scalars $subtree "") let extra = (derive_extra_vars $component $subtree) let merged = ($auto | merge $extra) let with_order = if "MANIFESTS_ORDER" in ($merged | columns) { $merged } else { let yamls = ($template_names | where {|n| $n | str ends-with ".yaml" }) $merged | insert MANIFESTS_ORDER (default_manifests_order $yamls) } $with_order | insert BUNDLE_ID $bundle_id | insert COMPONENT $component } def prepare [ncl_path: path, bundle_id: string]: nothing -> record { let exported = (nickel_export $ncl_path) let component = (detect_component $exported) let templates = (discover_templates $component) if ($templates | is-empty) { error make { msg: $"no templates found for component '($component)'" } } let vars = (build_vars $exported $component $bundle_id ($templates | get name)) let subtree = ($exported | get $component) let credentials = ($subtree | get -o requires | default {} | get -o credentials | default []) { component: $component templates: $templates vars: $vars credentials: $credentials } } # ─── rendering ──────────────────────────────────────────────────────── def render_one [tmpl_path: path, vars: record]: nothing -> string { mut content = (open --raw $tmpl_path) for kv in ($vars | transpose key value) { let needle = $"{{($kv.key)}}" $content = ($content | str replace --all $needle ($kv.value | into string)) } $content } def render_all [templates: list, vars: record]: nothing -> list { $templates | each {|t| { name: $t.name, content: (render_one $t.src $vars) } } } # ─── output ─────────────────────────────────────────────────────────── def copy_bundle_files [component: string, out_dir: path]: nothing -> list { let mdir = (component_manifests_dir $component) if not ($mdir | path exists) { return [] } glob ($mdir | path join "*.yaml") | each {|src| let name = ($src | path basename) ^cp $src ($out_dir | path join $name) $name } } def write_bundle [ out_dir: path rendered: list vars: record credentials: list ]: nothing -> nothing { mkdir $out_dir for r in $rendered { let dst = ($out_dir | path join $r.name) $r.content | save --force $dst if ($r.name | str ends-with ".sh") { ^chmod +x $dst } } copy_bundle_files $vars.COMPONENT $out_dir | ignore write_meta $out_dir $vars $rendered $credentials write_readme $out_dir $vars } def write_meta [ out_dir: path vars: record rendered: list credentials: list ]: nothing -> nothing { let meta = { component: $vars.COMPONENT bundle_id: $vars.BUNDLE_ID namespace: ($vars | get -o NAMESPACE | default "default") secret_name: $"($vars.COMPONENT)-credentials" credentials_required: $credentials manifests_order: ($vars.MANIFESTS_ORDER | split row " " | where {|s| ($s | str trim | is-not-empty) }) generated_at: (date now | format date "%Y-%m-%dT%H:%M:%S%z") files: ($rendered | get name) } $meta | to json --indent 2 | save --force ($out_dir | path join "bundle.meta.json") } def write_readme [out_dir: path, vars: record]: nothing -> nothing { let body = $"# ($vars.COMPONENT) bundle ($vars.BUNDLE_ID) Generated from NCL. Credential-free by design. Secret name expected in cluster: ($vars.COMPONENT)-credentials \(namespace: ($vars.NAMESPACE? | default 'default')\) Operations: install.sh full install \(applies manifests in declared order\) update.sh workload-only update \(preserves ns, pvc, secret\) delete.sh remove workload \(preserves pvc, secret\) purge.sh --confirm destructive: deletes pvc + secret \(preserves namespace\) All scripts: set -euo pipefail, trap ERR, STEP tracker, per-op .log file. " $body | save --force ($out_dir | path join "README") } # ─── modes ──────────────────────────────────────────────────────────── def mode_vars [ncl_path: path, bid: string]: nothing -> nothing { let p = (prepare $ncl_path $bid) $p.vars | transpose key value | sort-by key | each {|row| print $" ($row.key | fill --alignment left --width 28) ($row.value)" } | ignore } def mode_plan [ncl_path: path, out: path, bid: string]: nothing -> nothing { let p = (prepare $ncl_path $bid) let rendered = (render_all $p.templates $p.vars) print $"(ansi green_bold)[plan](ansi reset) component=($p.component)" print $"(ansi cyan)vars \(top 15\):(ansi reset)" $p.vars | transpose key value | sort-by key | first 15 | each {|row| print $" ($row.key | fill --alignment left --width 28) ($row.value)" } | ignore print $"\n(ansi cyan)files to be generated:(ansi reset) → ($out | path join $bid)" for r in $rendered { let bytes = ($r.content | str length) print $" ($r.name | fill --alignment left --width 22) ($bytes) bytes" } print $" bundle.meta.json \(metadata\)" print $" README \(usage\)" let prev_dir = if ($out | path exists) { ls $out | where type == dir | where name !~ $bid | sort-by modified | last 1 } else { [] } if ($prev_dir | is-not-empty) { let prev = $prev_dir.0.name print $"\n(ansi cyan)diff vs previous bundle(ansi reset): ($prev)" for r in $rendered { let prev_file = ($prev | path join $r.name) if ($prev_file | path exists) { let d = (do { $r.content | ^diff -u $prev_file - } | complete) if $d.exit_code != 0 { print $" (ansi yellow)~ ($r.name)(ansi reset)" print ($d.stdout | lines | skip 2 | str join "\n") } } else { print $" (ansi green)+ ($r.name) \(new\)(ansi reset)" } } } } def mode_render_stdout [ncl_path: path, bid: string]: nothing -> nothing { let p = (prepare $ncl_path $bid) let rendered = (render_all $p.templates $p.vars) let sep = "────────────────────────────────────────────────────────────────────────" for r in $rendered { print $"\n(ansi dark_gray)($sep)(ansi reset)" print $"(ansi cyan_bold)=== ($r.name) ===(ansi reset)" print $"(ansi dark_gray)($sep)(ansi reset)" print $r.content } } def mode_diff [ncl_path: path, bid: string]: nothing -> nothing { let p = (prepare $ncl_path $bid) let rendered = (render_all $p.templates $p.vars) let tmp = (mktemp --directory --tmpdir $"prvng-diff-($bid)-XXXX") write_bundle $tmp $rendered $p.vars $p.credentials print $"(ansi cyan)rendered to:(ansi reset) ($tmp)" for r in ($rendered | where name =~ '\.yaml$') { let file = ($tmp | path join $r.name) print $"\n(ansi cyan_bold)kubectl diff ($r.name)(ansi reset)" let out = (do { ^kubectl diff -f $file } | complete) match $out.exit_code { 0 => { print " (no changes)" } 1 => { print $out.stdout } _ => { print $"(ansi red)error:(ansi reset) ($out.stderr)" } } } print $"\n(ansi yellow)tmp kept for inspection:(ansi reset) ($tmp)" } def mode_write [ncl_path: path, out: path, bid: string]: nothing -> nothing { let p = (prepare $ncl_path $bid) let rendered = (render_all $p.templates $p.vars) let out_dir = ($out | path join $bid) write_bundle $out_dir $rendered $p.vars $p.credentials print $"bundle written: ($out_dir)" for r in $rendered { print $" ($r.name)" } print $" bundle.meta.json" print $" README" } # ─── entry ──────────────────────────────────────────────────────────── def main [ ncl_path: path --out (-o): path = "./out" --bundle-id (-b): string = "" --plan --render-stdout --diff --vars ]: nothing -> nothing { if not ($ncl_path | path exists) { error make { msg: $"ncl path not found: ($ncl_path)" } } let modes_on = ([$plan, $render_stdout, $diff, $vars] | where {|x| $x } | length) if $modes_on > 1 { error make { msg: "--plan, --render-stdout, --diff, --vars are mutually exclusive" } } let bid = if ($bundle_id | is-empty) { let stem = ($ncl_path | path basename | str replace --regex '\.ncl$' "") $"($stem)-(date now | format date '%Y%m%d-%H%M%S')" } else { $bundle_id } if $vars { mode_vars $ncl_path $bid } else if $plan { mode_plan $ncl_path $out $bid } else if $render_stdout { mode_render_stdout $ncl_path $bid } else if $diff { mode_diff $ncl_path $bid } else { mode_write $ncl_path $out $bid } }