#!/usr/bin/env nu # Publish content/config to a running pod, choosing the MINIMAL correct action from # the change's reload class (provisioning/distro.ncl :: reload_classes). # # Reload taxonomy (source of truth: distro.ncl): # hot markdown / images / CSS / static → served per request; no pod action # (r/ listing indexes refresh only after the in-process cache TTL) # config_reload rbac.ncl / htmx-templates → in-process reload via file watcher # (only when soft_reload_enabled; empty otherwise) # restart FTL i18n/locales / site/config → rolling restart + wait # rebuild config/rendering.ncl / new routes → image rebuild + deploy update (refused here) # # Dispatch: bucket every changed path, take the HIGHEST-severity class present, ship # the payload once, then run only that class's action. Any deliverable path not # matched by a class escalates conservatively to `restart`. # # Deploy identity (namespace / app_label / pvc / mount) is resolved from the site's # rustelo.manifest.toml — no per-site literals in this shared tool. # # Usage: # nu provisioning/content.nu pack [--root ] [--since ] [--all] # nu provisioning/content.nu deploy [--root ] [--pod ] [--namespace ] # nu provisioning/content.nu sync [--root ] [--since ] [--all] [--pod ] [--namespace ] # nu provisioning/content.nu classify [...] [--root ] [--since ] [--dry-run] # nu provisioning/content.nu publish [--root ] [--since ] [--all] [--pod ] [--namespace ] [--dry-run] # Severity order (low → high); a changeset's action is the highest class present. const CLASS_SEVERITY = ["hot", "config_reload", "restart", "rebuild"] const CONTENT_EXCLUDES = [ ".draft" ".DS_Store" "/_archive/" ] # --- deploy identity --------------------------------------------------------- # Resolve k8s deploy identity for the site rooted at content_root. Everything # derives from the site's rustelo.manifest.toml [project].name; only the k8s # namespace (and optional mount) is declared, under [deployment]. def site-identity [content_root: string]: nothing -> record { let manifest_path = $"($content_root)/rustelo.manifest.toml" if not ($manifest_path | path exists) { error make { msg: $"no rustelo.manifest.toml at ($content_root) — cannot resolve deploy identity" } } let m = (open $manifest_path) let name = $m.project.name let dep = (if ('deployment' in ($m | columns)) { $m.deployment } else { {} }) let dep_cols = ($dep | columns) { name: $name, namespace: (if ('namespace' in $dep_cols) { $dep.namespace } else { "" }), app_label: $"app.kubernetes.io/name=($name)", deploy: $name, pvc: $"($name)-data", mount: (if ('mount' in $dep_cols) { $dep.mount } else { "/var/www" }), cp_node: (if ('cp_node' in $dep_cols) { $dep.cp_node } else { "" }), kubeconfig: (if ('kubeconfig' in $dep_cols) { $dep.kubeconfig } else { "" }), } } def effective-ns [identity: record, override: string]: nothing -> string { let ns = if ($override | is-not-empty) { $override } else { $identity.namespace } if ($ns | is-empty) { error make { msg: "namespace unresolved — set [deployment].namespace in rustelo.manifest.toml or pass --namespace" } } $ns } # --- distro manifest readers ------------------------------------------------- # Hot-only subset (legacy `pack`/`sync` scope) from the single distro manifest. def content-dirs [manifest_root: string]: nothing -> list { ^nickel export $"($manifest_root)/provisioning/distro.ncl" --field content_hot --format json | from json } # Full PVC-deliverable surface: site_tree plus soft-reloadable extras (htmx-templates). # `publish` packs this so restart-class files (e.g. site/config) also reach the PVC. def deliverable-prefixes [manifest_root: string]: nothing -> list { let distro = (^nickel export $"($manifest_root)/provisioning/distro.ncl" --format json | from json) $distro.site_tree | append $distro.soft_reloadable | uniq } def reload-classes [manifest_root: string]: nothing -> record { ^nickel export $"($manifest_root)/provisioning/distro.ncl" --field reload_classes --format json | from json } # --- classifier -------------------------------------------------------------- def class-rank [cls: string]: nothing -> int { let idx = ($CLASS_SEVERITY | enumerate | where item == $cls | get index) if ($idx | is-empty) { 0 } else { $idx | first } } # Segment-boundary prefix match: a glob covers `path` if equal or a parent dir of it. def path-matches [path: string, glob: string]: nothing -> bool { $path == $glob or ($path | str starts-with $"($glob)/") } # Highest-severity class whose globs cover `path`; unmatched deliverable paths → restart. def classify-path [path: string, classes: record]: nothing -> string { let matched = ($CLASS_SEVERITY | where { |cls| ($classes | get $cls) | any { |g| path-matches $path $g } }) if ($matched | is-empty) { "restart" } else { $matched | last } } def highest-severity [classes: list]: nothing -> string { $classes | sort-by { |c| class-rank $c } | last } # --- transport (pluggable: direct kubectl | ssh to a control-plane node) ------ # POSIX single-quote: wrap in '' and escape embedded quotes, so the remote shell # passes kubectl args (jsonpath braces/brackets) through literally. def sh-quote [s: string]: nothing -> string { "'" + ($s | str replace --all "'" "'\\''") + "'" } def transport-desc [t: record]: nothing -> string { if $t.mode == "direct" { "direct kubectl" } else { $"ssh ($t.cp_node)" } } # Explicit cp_node (flag or site manifest) selects ssh transport; otherwise probe # local kubectl for direct API access. libre-wuji reaches the cluster only via ssh. def resolve-transport [cp_node: string, kubeconfig: string]: nothing -> record { if ($cp_node | is-not-empty) { { mode: "ssh", cp_node: $cp_node, kubeconfig: $kubeconfig } } else { let probe = (do { ^kubectl version --request-timeout=5s } | complete) if $probe.exit_code == 0 { { mode: "direct", cp_node: "", kubeconfig: $kubeconfig } } else { error make { msg: "no direct cluster access — set [deployment].cp_node in rustelo.manifest.toml or pass --cp-node " } } } } def remote-kubectl [t: record, args: list]: nothing -> string { let kc = if ($t.kubeconfig | is-empty) { "" } else { $"KUBECONFIG=($t.kubeconfig) " } $kc + "kubectl " + ($args | each { |a| sh-quote $a } | str join " ") } # Run a kubectl invocation under the chosen transport; returns the completion record. def kube-run [t: record, args: list]: nothing -> record { if $t.mode == "direct" { do { ^kubectl ...$args } | complete } else { do { ^ssh $t.cp_node (remote-kubectl $t $args) } | complete } } # Stream a local tarball into a pod via `tar -xzf - -C mount` (no cp, no temp file). # Same stdin pipe works over ssh — ssh forwards our binary stdin to the remote exec. def kube-stream-untar [t: record, pack_path: string, ns: string, pod: string, mount: string]: nothing -> nothing { let args = ["exec" "-i" "-n" $ns $pod "--" "tar" "-xzf" "-" "-C" $mount] if $t.mode == "direct" { open --raw $pack_path | ^kubectl ...$args } else { open --raw $pack_path | ^ssh $t.cp_node (remote-kubectl $t $args) } } # --- cluster helpers --------------------------------------------------------- def find-pod [t: record, app_label: string, ns: string]: nothing -> string { let r = (kube-run $t ["get" "pods" "-n" $ns "-l" $app_label "--no-headers" "-o" "jsonpath={.items[0].metadata.name}"]) if $r.exit_code != 0 or ($r.stdout | str trim | is-empty) { error make { msg: $"no running pod found for ($app_label) in ($ns) [(transport-desc $t)]" } } $r.stdout | str trim } def find-deploy [t: record, app_label: string, ns: string]: nothing -> string { let r = (kube-run $t ["get" "deploy" "-n" $ns "-l" $app_label "--no-headers" "-o" "jsonpath={.items[0].metadata.name}"]) if $r.exit_code != 0 or ($r.stdout | str trim | is-empty) { error make { msg: $"no deployment found for ($app_label) in ($ns) [(transport-desc $t)]" } } $r.stdout | str trim } # --- packing ----------------------------------------------------------------- def changed-files [content_root: string, since: string]: nothing -> list { let r = (do { cd $content_root; ^git diff --name-only $since HEAD } | complete) if $r.exit_code != 0 { error make { msg: $"git diff failed in ($content_root) — is it a git repo with ($since)?" } } $r.stdout | lines | where { |f| $f | is-not-empty } } def excluded? [f: string]: nothing -> bool { $CONTENT_EXCLUDES | any { |pat| $f | str contains $pat } } # Absolute paths of changed (or all, when --all) files under the given deliverable dirs. def content-files [content_root: string, dirs: list, since: string, all: bool]: nothing -> list { let candidates = if $all { $dirs | each { |d| do { ^find $"($content_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 $content_root $since) $changed | where { |f| $dirs | any { |d| path-matches $f $d } } | each { |f| $"($content_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 [content_root: string, dirs: list, since: string, all: bool]: nothing -> string { let timestamp = (date now | format date "%Y%m%dT%H%M%S") let git_sha = (do { cd $content_root; ^git rev-parse --short HEAD } | complete | if $in.exit_code == 0 { $in.stdout | str trim } else { "unknown" }) let pack_dir = $"($content_root)/provisioning/.content-packs" let outfile = $"($pack_dir)/($timestamp)-content.tgz" mkdir $pack_dir let files = (content-files $content_root $dirs $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" let kind = (if $all { "snapshot" } else { "incremental" }) { version: $timestamp, git_sha: $git_sha, kind: $kind, file_count: ($files | length), files: $files } | to json --raw | save -f $manifest_path let rel_files = ($files | each { |f| $f | str replace $"($content_root)/" "" }) $rel_files | str join "\n" | save -f $file_list_path let tar_bin = (pick-tar) do { cd $content_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 [t: record, pack_path: string, pod: string, ns: string, mount: string]: nothing -> nothing { print $"[deploy] ($pack_path) → ($pod):($mount) via (transport-desc $t)" kube-stream-untar $t $pack_path $ns $pod $mount print "[deploy] payload extracted to PVC" } def do-restart [t: record, app_label: string, ns: string]: nothing -> nothing { let deploy = (find-deploy $t $app_label $ns) print $"[restart] rolling restart deployment/($deploy) in ($ns) via (transport-desc $t)" (kube-run $t ["rollout" "restart" "-n" $ns $"deployment/($deploy)"]) | ignore (kube-run $t ["rollout" "status" "-n" $ns $"deployment/($deploy)" "--timeout=180s"]) | ignore print "[restart] complete" } # --- publish (classify + dispatch) ------------------------------------------- # manifest_root holds distro.ncl (shared source project); content_root holds the # site tree + its git history (may be a different repo). identity is resolved from # the site manifest; ns is the effective (override-aware) namespace. def do-publish [content_root: string, manifest_root: string, identity: record, ns: string, transport: record, since: string, all: bool, pod: string, snapshot_enabled: bool, keep: int, dry_run: bool]: nothing -> nothing { let prefixes = (deliverable-prefixes $manifest_root) let files = (content-files $content_root $prefixes $since $all) if ($files | is-empty) { print "no deployable changes"; return } let classes = (reload-classes $manifest_root) let rel = ($files | each { |f| $f | str replace $"($content_root)/" "" }) let classified = ($rel | each { |f| { path: $f, class: (classify-path $f $classes) } }) let overall = (highest-severity ($classified | get class)) let drivers = ($classified | where class == $overall | get path) let sample = ($drivers | first 3 | str join ", ") print $"[publish] ($files | length) files → class=($overall) [($sample)] → ($identity.deploy)@($ns) via (transport-desc $transport)" if $overall == "rebuild" { print "[publish] REFUSED: rebuild-class change (render profile flip / new routes)." print "[publish] run: just build-push-remote && nu provisioning/deploy.nu update" return } if $dry_run { let action = (match $overall { "hot" => "sync, no restart" "config_reload" => "sync, in-process watcher reload" "restart" => "sync + rolling restart" _ => "sync" }) print $"[publish] DRY-RUN → would ($action) via (transport-desc $transport) into ($ns)" return } # restart-class mutates FTL/config the server re-reads: snapshot the volume (DB # + full tree, atomic) BEFORE overwriting, so a botched update is recoverable. if $overall == "restart" and $snapshot_enabled { do-snapshot $identity $ns $transport $keep false | ignore } let pack_path = (do-pack $content_root $prefixes $since $all) if ($pack_path | is-empty) { return } let resolved_pod = if ($pod | is-not-empty) { $pod } else { find-pod $transport $identity.app_label $ns } do-deploy $transport $pack_path $resolved_pod $ns $identity.mount match $overall { "hot" => { print "[publish] class=hot → synced, no restart (index listings refresh after cache TTL)" } "config_reload" => { print "[publish] class=config_reload → synced; in-process reload via file watcher (soft_reload_enabled)" } "restart" => { do-restart $transport $identity.app_label $ns } } } # --- Longhorn snapshot safety net (DB + full tree, atomic) ------------------- # Longhorn CRs live in longhorn-system; a Snapshot targets the Longhorn VOLUME # (the PV name), not the PVC. site.db (non-git SQLite) is only recoverable this way. const LH_NS = "longhorn-system" def kube-apply [t: record, manifest_yaml: string]: nothing -> nothing { let args = ["apply" "-f" "-"] if $t.mode == "direct" { $manifest_yaml | ^kubectl ...$args } else { $manifest_yaml | ^ssh $t.cp_node (remote-kubectl $t $args) } } def resolve-volume [t: record, pvc: string, ns: string]: nothing -> string { let r = (kube-run $t ["get" "pvc" $pvc "-n" $ns "-o" "jsonpath={.spec.volumeName}"]) if $r.exit_code != 0 or ($r.stdout | str trim | is-empty) { error make { msg: $"cannot resolve PVC ($pvc) → Longhorn volume in ($ns)" } } $r.stdout | str trim } # List this site's pre-update snapshots (name + creation), oldest first. def list-snapshots [t: record, site: string]: nothing -> list { let r = (kube-run $t ["get" "snapshots.longhorn.io" "-n" $LH_NS "-l" $"site=($site)" "--sort-by=.metadata.creationTimestamp" "-o" "jsonpath={range .items[*]}{.metadata.name}\n{end}"]) if $r.exit_code != 0 { return [] } $r.stdout | lines | where { |l| $l | is-not-empty } } def prune-snapshots [t: record, site: string, keep: int]: nothing -> nothing { let names = (list-snapshots $t $site) let excess = (($names | length) - $keep) if $excess > 0 { $names | first $excess | each { |n| print $"[snapshot] prune (keep-($keep)): ($n)" (kube-run $t ["delete" "snapshots.longhorn.io" $n "-n" $LH_NS]) | ignore } | ignore } } # Create a pre-update Longhorn snapshot `-pre-` of the site's volume, # then prune to keep-N. Requires the volume ATTACHED (a running pod). Returns name. def do-snapshot [identity: record, ns: string, t: record, keep: int, dry_run: bool]: nothing -> string { let vol = (resolve-volume $t $identity.pvc $ns) let ts = (date now | format date "%Y%m%d-%H%M%S") let name = $"($identity.deploy)-pre-($ts)" print $"[snapshot] ($name) → volume ($vol) [(($LH_NS))] via (transport-desc $t)" if $dry_run { print "[snapshot] DRY-RUN → no snapshot created"; return $name } let manifest = ({ apiVersion: "longhorn.io/v1beta2", kind: "Snapshot", metadata: { name: $name, namespace: $LH_NS, labels: { site: $identity.deploy, purpose: "pre-update" } }, spec: { volume: $vol, createSnapshot: true, labels: { site: $identity.deploy } }, } | to yaml) kube-apply $t $manifest prune-snapshots $t $identity.deploy $keep print $"[snapshot] created ($name) (keep-($keep) retention applied)" $name } # Revert the site's volume to a snapshot. DISRUPTIVE: scales the workload to 0 # (detach), reverts via the Longhorn backend API (attach-maintenance → # snapshotRevert → detach), then scales back. Gated behind --confirm; ssh # transport only (needs in-cluster reach to longhorn-backend). Recovers the DB. def do-restore [identity: record, ns: string, t: record, snap: string, confirm: bool, dry_run: bool]: nothing -> nothing { if ($snap | is-empty) { error make { msg: "restore needs a snapshot name (see `content.nu snapshots`)" } } let vol = (resolve-volume $t $identity.pvc $ns) print $"[restore] revert volume ($vol) → snapshot ($snap) | ($identity.deploy)@($ns) — DISRUPTIVE, scales workload to 0" if $dry_run or (not $confirm) { print "[restore] PLAN (add --confirm to execute):" print $" 1. kubectl scale -n ($ns) deployment/($identity.deploy) --replicas=0 # detach" print $" 2. longhorn snapshotRevert vol=($vol) snapshot=($snap) # attach-maint → revert → detach" print $" 3. kubectl scale -n ($ns) deployment/($identity.deploy) --replicas=1 # reattach" return } if $t.mode != "ssh" { error make { msg: "restore requires ssh transport (in-cluster reach to longhorn-backend) — pass --cp-node" } } print "[restore] 1/4 scaling to 0 (detach)" (kube-run $t ["scale" "-n" $ns $"deployment/($identity.deploy)" "--replicas=0"]) | ignore (kube-run $t ["wait" $"volumes.longhorn.io/($vol)" "-n" $LH_NS "--for=jsonpath={.status.state}=detached" "--timeout=120s"]) | ignore let node = ((kube-run $t ["get" "nodes" "-o" "jsonpath={.items[0].metadata.name}"]).stdout | str trim) let api = ((kube-run $t ["get" "svc" "longhorn-backend" "-n" $LH_NS "-o" "jsonpath={.spec.clusterIP}:{.spec.ports[0].port}"]).stdout | str trim) let base = $"http://($api)/v1/volumes/($vol)" print $"[restore] 2/4 attach-maintenance on ($node) via ($api)" (do { ^ssh $t.cp_node $"curl -sf -X POST '($base)?action=attach' -H 'Content-Type: application/json' -d '{\"hostId\":\"($node)\",\"disableFrontend\":true}'" } | complete) | ignore (kube-run $t ["wait" $"volumes.longhorn.io/($vol)" "-n" $LH_NS "--for=jsonpath={.status.state}=attached" "--timeout=120s"]) | ignore print $"[restore] 3/4 snapshotRevert → ($snap)" (do { ^ssh $t.cp_node $"curl -sf -X POST '($base)?action=snapshotRevert' -H 'Content-Type: application/json' -d '{\"name\":\"($snap)\"}'" } | complete) | ignore (do { ^ssh $t.cp_node $"curl -sf -X POST '($base)?action=detach'" } | complete) | ignore print "[restore] 4/4 scaling back up (reattach)" (kube-run $t ["scale" "-n" $ns $"deployment/($identity.deploy)" "--replicas=1"]) | ignore print "[restore] complete — volume reverted, workload restarted" } # --- ledger / rollback ------------------------------------------------------- def site-tree-paths [manifest_root: string]: nothing -> list { ^nickel export $"($manifest_root)/provisioning/distro.ncl" --field site_tree --format json | from json } # Render the pack ledger: version, git_sha, kind (snapshot|incremental), file count. def do-list [content_root: string]: nothing -> table { let pack_dir = $"($content_root)/provisioning/.content-packs" let manifests = (glob $"($pack_dir)/*-manifest.json") if ($manifests | is-empty) { print "no content packs in ledger"; return [] } $manifests | each { |m| let j = (open $m) let cols = ($j | columns) { version: $j.version, git_sha: $j.git_sha, kind: (if ('kind' in $cols) { $j.kind } else { "incremental" }), files: $j.file_count, } } | sort-by version } def resolve-rollback-sha [content_root: string, version: string, to_sha: string]: nothing -> string { if ($to_sha | is-not-empty) { return $to_sha } if ($version | is-empty) { error make { msg: "rollback needs or --to-sha " } } let m = $"($content_root)/provisioning/.content-packs/($version)-manifest.json" if not ($m | path exists) { error make { msg: $"no ledger entry for version ($version)" } } let sha = (open $m | get git_sha) if ($sha == "unknown") { error make { msg: $"ledger entry ($version) has no git_sha (packed outside a git repo)" } } $sha } # Deliverable paths that exist in the tree at (git archive + rm units). def present-at [content_root: string, sha: string, paths: list]: nothing -> list { $paths | where { |p| (do { cd $content_root; ^git cat-file -e $"($sha):($p)" } | complete | get exit_code) == 0 } } # Full-payload tarball of the given paths at (never an incremental replay). def git-archive-payload [content_root: string, sha: string, paths: list, pack_dir: string]: nothing -> string { mkdir $pack_dir let tar_path = $"($pack_dir)/rollback-($sha).tar" let r = (do { cd $content_root; ^git archive -o $tar_path --format=tar $sha ...$paths } | complete) if $r.exit_code != 0 { error make { msg: $"git archive failed at ($sha)" } } ^gzip -f $tar_path $"($tar_path).gz" } # Deletion-aware restore: remove each unit under mount, then extract the full # payload — so files added since within those units are gone (§10). Units # never include works-pv / the DB, which stay intact. def kube-rollback-restore [t: record, pod: string, ns: string, mount: string, units: list, payload: string]: nothing -> nothing { let targets = ($units | each { |u| $"($mount)/($u)" } | str join " ") print $"[rollback] deletion-aware wipe of ($units | length) units under ($mount), then extract" (kube-run $t ["exec" "-n" $ns $pod "--" "sh" "-c" $"rm -rf ($targets)"]) | ignore kube-stream-untar $t $payload $ns $pod $mount } # Roll the live site back to a target sha via the FULL site_tree at that sha # (deletion-aware), then dispatch the same class action the forward update would. def do-rollback [content_root: string, manifest_root: string, identity: record, ns: string, transport: record, version: string, to_sha: string, pod: string, snapshot_enabled: bool, keep: int, dry_run: bool]: nothing -> nothing { let sha = (resolve-rollback-sha $content_root $version $to_sha) let deliv = (deliverable-prefixes $manifest_root) let units = (present-at $content_root $sha $deliv) if ($units | is-empty) { error make { msg: $"no deliverable paths present at ($sha) — nothing to restore" } } let classes = (reload-classes $manifest_root) let diff = (changed-files $content_root $sha) let rel = ($diff | where { |f| $deliv | any { |d| path-matches $f $d } }) let cls = if ($rel | is-empty) { "hot" } else { highest-severity ($rel | each { |f| classify-path $f $classes }) } print $"[rollback] → ($sha) | class=($cls) | units=($units | length) | ($identity.deploy)@($ns) via (transport-desc $transport)" if $dry_run { let action = (match $cls { "restart" => "rolling restart", "rebuild" => "image rollback (manual)", _ => "no restart" }) print $"[rollback] DRY-RUN → would: full restore-point pack; git archive ($sha) [(($units | str join ', '))]; deletion-aware restore; then ($action)" return } print "[rollback] restore-point: full pack of current state (reversible)" do-pack $content_root $deliv "" true | ignore # restart/rebuild rollbacks touch config/DB-adjacent state: snapshot the volume # (the only recover path for the non-git DB) before the deletion-aware wipe. if ($cls in ["restart" "rebuild"]) and $snapshot_enabled { do-snapshot $identity $ns $transport $keep false | ignore } let pack_dir = $"($content_root)/provisioning/.content-packs" let payload = (git-archive-payload $content_root $sha $units $pack_dir) let resolved_pod = if ($pod | is-not-empty) { $pod } else { find-pod $transport $identity.app_label $ns } kube-rollback-restore $transport $resolved_pod $ns $identity.mount $units $payload match $cls { "restart" => { do-restart $transport $identity.app_label $ns } "rebuild" => { print $"[rollback] class=rebuild: also re-pin the previous image_tag in the shim + `kubectl rollout undo deployment/($identity.deploy) -n ($ns)`" } _ => { print "[rollback] restored; no restart required" } } } def main [ subcmd: string = "sync" ...rest: string --since: string = "HEAD~1" --all --pod: string = "" --namespace: string = "" # override; defaults to the site manifest's namespace --root: string = "" # content source-of-truth root (a git repo); the site # tree lives here. Defaults to this project (source repo). --cp-node: string = "" # control-plane host for ssh transport; overrides the # site manifest's [deployment].cp_node. Empty → try direct. --kubeconfig: string = "" # remote KUBECONFIG for ssh transport (default: none) --to-sha: string = "" # rollback target git sha (alternative to ) --keep: int = 5 # Longhorn pre-update snapshot retention (keep-N) --no-snapshot # skip the pre-update volume snapshot on restart/rebuild --confirm # required to actually execute `restore` (destructive) --dry-run ]: nothing -> nothing { let manifest_root = ($env.FILE_PWD | path dirname) let content_root = if ($root | is-not-empty) { $root } else { $manifest_root } match $subcmd { "pack" => { do-pack $content_root (content-dirs $manifest_root) $since $all | ignore } "deploy" => { let identity = (site-identity $content_root) let ns = (effective-ns $identity $namespace) let transport = (resolve-transport (if ($cp_node | is-not-empty) { $cp_node } else { $identity.cp_node }) (if ($kubeconfig | is-not-empty) { $kubeconfig } else { $identity.kubeconfig })) let pack_dir = $"($content_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 $transport $identity.app_label $ns } do-deploy $transport $latest $resolved_pod $ns $identity.mount } "sync" => { let identity = (site-identity $content_root) let ns = (effective-ns $identity $namespace) let transport = (resolve-transport (if ($cp_node | is-not-empty) { $cp_node } else { $identity.cp_node }) (if ($kubeconfig | is-not-empty) { $kubeconfig } else { $identity.kubeconfig })) let pack_path = (do-pack $content_root (content-dirs $manifest_root) $since $all) if ($pack_path | is-not-empty) { let resolved_pod = if ($pod | is-not-empty) { $pod } else { find-pod $transport $identity.app_label $ns } do-deploy $transport $pack_path $resolved_pod $ns $identity.mount } } "classify" => { let classes = (reload-classes $manifest_root) let paths = if ($rest | is-not-empty) { $rest } else { changed-files $content_root $since } let classified = ($paths | each { |p| { path: $p, class: (classify-path $p $classes) } }) for row in $classified { print $"($row.class)\t($row.path)" } let overall = if ($classified | is-empty) { "hot" } else { highest-severity ($classified | get class) } print $overall } "publish" => { let identity = (site-identity $content_root) let ns = (effective-ns $identity $namespace) let transport = (resolve-transport (if ($cp_node | is-not-empty) { $cp_node } else { $identity.cp_node }) (if ($kubeconfig | is-not-empty) { $kubeconfig } else { $identity.kubeconfig })) do-publish $content_root $manifest_root $identity $ns $transport $since $all $pod (not $no_snapshot) $keep $dry_run } "list" => { do-list $content_root } "rollback" => { let identity = (site-identity $content_root) let ns = (effective-ns $identity $namespace) let transport = (resolve-transport (if ($cp_node | is-not-empty) { $cp_node } else { $identity.cp_node }) (if ($kubeconfig | is-not-empty) { $kubeconfig } else { $identity.kubeconfig })) let version = if ($rest | is-empty) { "" } else { $rest | first } do-rollback $content_root $manifest_root $identity $ns $transport $version $to_sha $pod (not $no_snapshot) $keep $dry_run } "snapshot" => { let identity = (site-identity $content_root) let ns = (effective-ns $identity $namespace) let transport = (resolve-transport (if ($cp_node | is-not-empty) { $cp_node } else { $identity.cp_node }) (if ($kubeconfig | is-not-empty) { $kubeconfig } else { $identity.kubeconfig })) do-snapshot $identity $ns $transport $keep $dry_run | ignore } "snapshots" => { let identity = (site-identity $content_root) let transport = (resolve-transport (if ($cp_node | is-not-empty) { $cp_node } else { $identity.cp_node }) (if ($kubeconfig | is-not-empty) { $kubeconfig } else { $identity.kubeconfig })) list-snapshots $transport $identity.deploy | each { |n| print $n } | ignore } "restore" => { let identity = (site-identity $content_root) let ns = (effective-ns $identity $namespace) let transport = (resolve-transport (if ($cp_node | is-not-empty) { $cp_node } else { $identity.cp_node }) (if ($kubeconfig | is-not-empty) { $kubeconfig } else { $identity.kubeconfig })) let snap = if ($rest | is-empty) { "" } else { $rest | first } do-restore $identity $ns $transport $snap $confirm $dry_run } _ => { print "Usage: content.nu [||...] [--root ] [--since ] [--all] [--pod ] [--namespace ] [--cp-node ] [--kubeconfig ] [--to-sha ] [--keep ] [--no-snapshot] [--confirm] [--dry-run]" } } }