#!/usr/bin/env nu # Hot-deploy content to running pod without restart (within hot-reload caveats below). # # Hot-reload caveats: # Markdown / images / CSS / static: hot-reload works (filesystem-served per request) # FTL translation changes (any): requires restart — registry loaded at startup # New routes or new pages: requires full rebuild (build.nu) + deploy.nu update # # Usage: # nu provisioning/content.nu pack [--since ] [--all] # nu provisioning/content.nu deploy [--pod ] [--namespace ] # nu provisioning/content.nu sync [--since ] [--all] [--pod ] [--namespace ] # Hot-updatable paths come from the single distro manifest (provisioning/distro.ncl), # the same source the Dockerfiles and `just distro` read — no divergent lists. def content-dirs [project_root: string]: nothing -> list { ^nickel export $"($project_root)/provisioning/distro.ncl" --field content_hot --format json | from json } const CONTENT_EXCLUDES = [ ".draft" ".DS_Store" "/_archive/" ] const PVC_MOUNT = "/var/www" const NAMESPACE = "example-pro" const APP_LABEL = "app.kubernetes.io/name=website" def find-pod [ns: string]: nothing -> string { let r = (do { ^kubectl get pods -n $ns -l $APP_LABEL --no-headers -o jsonpath="{.items[0].metadata.name}" } | complete) if $r.exit_code != 0 or ($r.stdout | str trim | is-empty) { error make { msg: $"no running pod found for ($APP_LABEL) in ($ns)" } } $r.stdout | str trim } def changed-files [since: string]: nothing -> list { let r = (do { ^git diff --name-only $since HEAD } | complete) if $r.exit_code != 0 { error make { msg: "git diff failed" } } $r.stdout | lines | where { |f| $f | is-not-empty } } def excluded? [f: string]: nothing -> bool { CONTENT_EXCLUDES | any { |pat| $f | str contains $pat } } def content-files [project_root: string, since: string, all: bool]: nothing -> list { let content_dirs = (content-dirs $project_root) let candidates = if $all { $content_dirs | each { |d| do { ^find $"($project_root)/($d)" -type f } | complete | if $in.exit_code == 0 { $in.stdout | lines | where { |l| $l | is-not-empty } } else { [] } } | flatten } else { let changed = (changed-files $since) $changed | where { |f| $content_dirs | any { |d| $f | str starts-with $d } } | each { |f| $"($project_root)/($f)" } } $candidates | where { |f| $f | path exists } | where { |f| not (excluded? $f) } } def pick-tar []: nothing -> string { let gtar = (do { ^which gtar } | complete) if $gtar.exit_code == 0 { ($gtar.stdout | str trim) } else { "tar" } } def do-pack [project_root: string, since: string, all: bool]: nothing -> string { let timestamp = (date now | format date "%Y%m%dT%H%M%S") let git_sha = (do { ^git rev-parse --short HEAD } | complete | if $in.exit_code == 0 { $in.stdout | str trim } else { "unknown" }) let pack_dir = $"($project_root)/provisioning/.content-packs" let outfile = $"($pack_dir)/($timestamp)-content.tgz" mkdir $pack_dir let files = (content-files $project_root $since $all) if ($files | is-empty) { print "no content files to pack"; return "" } let manifest_path = $"($pack_dir)/($timestamp)-manifest.json" let file_list_path = $"($pack_dir)/($timestamp)-files.txt" { version: $timestamp, git_sha: $git_sha, file_count: ($files | length), files: $files } | to json --raw | save -f $manifest_path let rel_files = ($files | each { |f| $f | str replace $"($project_root)/" "" }) $rel_files | str join "\n" | save -f $file_list_path let tar_bin = (pick-tar) do { cd $project_root; ^$tar_bin -czf $outfile -T $file_list_path } | complete | if $in.exit_code != 0 { error make { msg: "tar failed" } } print $"packed ($files | length) files → ($outfile)" $outfile } def do-deploy [pack_path: string, pod: string, ns: string]: nothing -> nothing { let remote_tmp = $"/tmp/($pack_path | path basename)" print $"[deploy] ($pack_path) → ($pod):($remote_tmp)" ^kubectl cp $pack_path $"($ns)/($pod):($remote_tmp)" ^kubectl exec -n $ns $pod -- tar -xzf $remote_tmp -C $PVC_MOUNT ^kubectl exec -n $ns $pod -- rm -f $remote_tmp print "[deploy] done — no restart required (markdown/CSS/static)" print "[deploy] note: FTL changes require: nu provisioning/deploy.nu restart" } def main [ subcmd: string = "sync" --since: string = "HEAD~1" --all --pod: string = "" --namespace: string = $NAMESPACE ]: nothing -> nothing { let project_root = ($env.FILE_PWD | path dirname) match $subcmd { "pack" => { do-pack $project_root $since $all | ignore } "deploy" => { let pack_dir = $"($project_root)/provisioning/.content-packs" let tgz_files = (glob $"($pack_dir)/*.tgz") if ($tgz_files | is-empty) { error make { msg: "no packs found — run pack first" } } let latest = ($tgz_files | each { |p| { name: $p, mtime: (ls $p | first | get modified) } } | sort-by mtime | last | get name) let resolved_pod = if ($pod | is-not-empty) { $pod } else { find-pod $namespace } do-deploy $latest $resolved_pod $namespace } "sync" => { let pack_path = (do-pack $project_root $since $all) if ($pack_path | is-not-empty) { let resolved_pod = if ($pod | is-not-empty) { $pod } else { find-pod $namespace } do-deploy $pack_path $resolved_pod $namespace } } _ => { print "Usage: content.nu [--since ] [--all] [--pod ] [--namespace ]" } } }