website-htmx-rustelo-code/provisioning/content.nu
2026-07-10 03:44:13 +01:00

608 lines
32 KiB
Text

#!/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 <site>] [--since <git-ref>] [--all]
# nu provisioning/content.nu deploy [--root <site>] [--pod <name>] [--namespace <ns>]
# nu provisioning/content.nu sync [--root <site>] [--since <git-ref>] [--all] [--pod <name>] [--namespace <ns>]
# nu provisioning/content.nu classify [<path>...] [--root <site>] [--since <git-ref>] [--dry-run]
# nu provisioning/content.nu publish [--root <site>] [--since <git-ref>] [--all] [--pod <name>] [--namespace <ns>] [--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<string> {
^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<string> {
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<string>]: 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 <host>" }
}
}
}
def remote-kubectl [t: record, args: list<string>]: 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<string>]: 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<string> {
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<string>, since: string, all: bool]: nothing -> list<string> {
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<string>, 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<string> {
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 `<deploy>-pre-<ts>` 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<string> {
^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 <version> or --to-sha <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 <sha> (git archive + rm units).
def present-at [content_root: string, sha: string, paths: list<string>]: nothing -> list<string> {
$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 <sha> (never an incremental replay).
def git-archive-payload [content_root: string, sha: string, paths: list<string>, 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 <sha> 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<string>, 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 <version>)
--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 <pack|deploy|sync|classify|publish|list|rollback|snapshot|snapshots|restore> [<path>|<version>|<snap>...] [--root <site>] [--since <ref>] [--all] [--pod <name>] [--namespace <ns>] [--cp-node <host>] [--kubeconfig <path>] [--to-sha <sha>] [--keep <n>] [--no-snapshot] [--confirm] [--dry-run]"
}
}
}