143 lines
5.8 KiB
Text
143 lines
5.8 KiB
Text
#!/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 <git-ref>] [--all]
|
|
# nu provisioning/content.nu deploy [--pod <name>] [--namespace <ns>]
|
|
# nu provisioning/content.nu sync [--since <git-ref>] [--all] [--pod <name>] [--namespace <ns>]
|
|
|
|
# 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<string> {
|
|
^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<string> {
|
|
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<string> {
|
|
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 <pack|deploy|sync> [--since <ref>] [--all] [--pod <name>] [--namespace <ns>]"
|
|
}
|
|
}
|
|
}
|