#!/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 # MOSAIC_ENSURE_SKIP: the gate's run must not re-enter release # self-determination (recursion guard). MOSAIC_ENSURE_SKIP=1 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" } cmd_ensure() { # M16 release self-determination: the system determines what is installed # and aligns itself. Aligned (pointer == RELEASE, image present) -> no-op. # Drifted -> package if needed, then health-gated activate. local pointer="$MOSAIC_DEV_DIR/state/active.json" local active="" if [ -f "$pointer" ]; then active="$(node -e 'try{const p=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(p.release||"")}catch{}' "$pointer")" fi if [ "$active" = "$MOSAIC_RELEASE" ] && image_exists "$MOSAIC_IMAGE_TAG"; then echo "release: aligned at $MOSAIC_RELEASE ($MOSAIC_IMAGE_TAG)" return 0 fi echo "release: drift (active: ${active:-none}, desired: $MOSAIC_RELEASE) - aligning" image_exists "$MOSAIC_IMAGE_TAG" || cmd_package activate "$MOSAIC_RELEASE" "$MOSAIC_IMAGE_TAG" "activate" } case "${1:-}" in package) cmd_package ;; activate) shift; cmd_activate "$@" ;; rollback) cmd_rollback ;; status) cmd_status ;; ensure) shift; cmd_ensure "$@" ;; *) echo "usage: scripts/release.sh package | activate [--fault-injection] | rollback | status | ensure" >&2; exit 4 ;; esac