608 lines
32 KiB
Text
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]"
|
|
}
|
|
}
|
|
}
|