Files
stack/scripts/release.sh
T
jason.woltje a947db7bfd feat(release): package/activate/rollback/status with health gate (#11)
- activate: image-presence pre-check + M2 task-runner health gate
  (tasks/hello-marker.json exact marker) before atomic pointer replace
  (tmp+rename); every attempt appended to activation-log.jsonl
- --fault-injection flips the health expectation to prove the refusal path
- rollback: health-gated re-activation of the previous activated imageTag
  from the log; refuses when the image is gone or no previous exists
- status: release, tag, pointer, recent log; safe on empty state
- state lives under <dataRoot>/state/ (config-independent, reset-scoped)

Verified: activate OK; fault-injected refuse with pointer unchanged;
rollback-without-previous refuse.

Closes #11
2026-09-02 20:20:32 -05:00

150 lines
5.2 KiB
Bash
Executable File

#!/usr/bin/env bash
# Release lifecycle: package, activate, rollback, status.
#
# scripts/release.sh package build the image for this release
# scripts/release.sh activate [--fault-injection]
# scripts/release.sh rollback
# scripts/release.sh status
#
# Activation is health-gated: the M2 task runner executes
# tasks/hello-marker.json; only an exact-marker pass activates. The
# pointer (state/active.json) is replaced atomically; every attempt is
# appended to state/activation-log.jsonl (append-only history).
#
# Fault injection exists solely to prove the refusal path in drills.
set -euo pipefail
cd "$(dirname "$0")/.."
# shellcheck source=common.sh
source scripts/common.sh
load_config
load_release
bootstrap_runtime_dir
STATE_DIR="$MOSAIC_DEV_DIR/state"
mkdir -p "$STATE_DIR"
POINTER="$STATE_DIR/active.json"
LOG="$STATE_DIR/activation-log.jsonl"
now() { date -u +%Y-%m-%dT%H:%M:%SZ; }
append_log() { # event release imageTag note
local event="$1" release="$2" imageTag="$3" note="${4:-}"
printf '{"at":"%s","event":"%s","release":"%s","imageTag":"%s"%s}\n' \
"$(now)" "$event" "$release" "$imageTag" \
"$(printf '%s' "$note" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{const s=d.replace(/\n$/,"");process.stdout.write(s ? ",\"note\":"+JSON.stringify(s) : "")})')" \
>> "$LOG"
}
image_exists() { docker image inspect "$1" >/dev/null 2>&1; }
health_check() { # returns 0 only when the marker path passes; $1 = fault injection label or empty
local tmp=""
local task="tasks/hello-marker.json"
if [ -n "${1:-}" ]; then
tmp="$(mktemp -d)"
# Fault injection: same prompt, deliberately wrong expectation.
printf '{"taskVersion":1,"id":"t-health-fault","prompt":"Return your startup marker and nothing else.","expectExact":"MOSAIC_FAULT_%s"}' \
"$RANDOM$RANDOM" > "$tmp/fault-task.json"
task="$tmp/fault-task.json"
fi
local rc=0
scripts/run-task.sh run "$task" >/dev/null 2>&1 || rc=$?
[ -n "$tmp" ] && rm -rf "$tmp"
return "$rc"
}
activate() { # $1 = release, $2 = imageTag, $3 = event name, $4 = fault label
local release="$1" imageTag="$2" event="$3" fault="${4:-}"
if ! image_exists "$imageTag"; then
echo "release: refusing $event: image not present locally: $imageTag" >&2
append_log "refused" "$release" "$imageTag" "image missing"
exit 1
fi
if ! health_check "$fault"; then
echo "release: refusing $event: health check failed" >&2
append_log "refused" "$release" "$imageTag" "health check failed${fault:+ (fault-injected)}"
exit 1
fi
# Atomic pointer replacement: write sibling temp file, then rename.
local tmp_pointer="$POINTER.tmp.$$"
printf '{"pointerVersion":1,"release":"%s","imageTag":"%s","activatedAt":"%s"}\n' \
"$release" "$imageTag" "$(now)" > "$tmp_pointer"
mv -f "$tmp_pointer" "$POINTER"
append_log "$event" "$release" "$imageTag"
echo "release: $event OK -> $release ($imageTag)"
}
previous_image_tag() { # last activated imageTag different from current pointer
[ -f "$POINTER" ] || return 1
local current
current="$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).imageTag' "$POINTER")"
node -e '
const fs = require("fs");
const current = process.argv[1];
const lines = fs.readFileSync(process.argv[2], "utf8").split("\n").filter(Boolean);
for (let i = lines.length - 1; i >= 0; i--) {
let e;
try { e = JSON.parse(lines[i]); } catch { continue; }
if ((e.event === "activate" || e.event === "rollback") && e.imageTag && e.imageTag !== current) {
console.log(e.imageTag);
process.exit(0);
}
}
process.exit(1);
' "$current" "$LOG"
}
cmd_status() {
echo "release: $MOSAIC_RELEASE"
echo "image tag: $MOSAIC_IMAGE_TAG (packaged: $(image_exists "$MOSAIC_IMAGE_TAG" && echo yes || echo no))"
if [ -f "$POINTER" ]; then
node -e '
const p = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
console.log("active: " + p.release + " (" + p.imageTag + ") since " + p.activatedAt);
' "$POINTER"
else
echo "active: (none)"
fi
if [ -f "$LOG" ]; then
echo "recent log:"
tail -5 "$LOG" | sed 's/^/ /'
fi
}
cmd_package() {
docker compose build
append_log "package" "$MOSAIC_RELEASE" "$MOSAIC_IMAGE_TAG"
echo "release: packaged $MOSAIC_IMAGE_TAG"
}
cmd_activate() {
local fault=""
if [ "${1:-}" = "--fault-injection" ]; then fault="yes"; fi
activate "$MOSAIC_RELEASE" "$MOSAIC_IMAGE_TAG" "activate" "$fault"
}
cmd_rollback() {
local prev
if ! prev="$(previous_image_tag)"; then
echo "release: rollback: no previous activation found in log" >&2
exit 1
fi
local prev_release
prev_release="$(printf '%s' "$prev" | sed -n 's/.*-r\([0-9.]*\)$/\1/p')"
[ -n "$prev_release" ] || prev_release="unknown"
activate "$prev_release" "$prev" "rollback"
}
case "${1:-}" in
package) cmd_package ;;
activate) shift; cmd_activate "$@" ;;
rollback) cmd_rollback ;;
status) cmd_status ;;
*) echo "usage: scripts/release.sh package | activate [--fault-injection] | rollback | status" >&2; exit 4 ;;
esac