- 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
150 lines
5.2 KiB
Bash
Executable File
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
|