provisioning-catalog/components/_renderer/render.nu

398 lines
14 KiB
Text

#!/usr/bin/env nu
# Generic component bundle renderer.
#
# Discovers templates from _renderer/common-templates/ (shared scripts) and
# <component>/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 <component>/cluster/vars.nu
# - MANIFESTS_ORDER: explicit via vars.nu, or derived from YAML kinds
# - BUNDLE_ID, COMPONENT: injected
#
# Modes:
# (default) Write bundle to --out/<bundle-id>/
# --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<record<name: string, src: path>> {
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<string>]: 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<string>
]: 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<record>, vars: record]: nothing -> list<record> {
$templates | each {|t| { name: $t.name, content: (render_one $t.src $vars) } }
}
# ─── output ───────────────────────────────────────────────────────────
def copy_bundle_files [component: string, out_dir: path]: nothing -> list<string> {
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<record>
vars: record
credentials: list<string>
]: 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<record>
credentials: list<string>
]: 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
}
}