398 lines
14 KiB
Text
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
|
|
}
|
|
}
|