diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 00000000..e67c9c0d --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,149 @@ +#!/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