diff --git a/release-goosed-remount-guard.test.mjs b/release-goosed-remount-guard.test.mjs index d1ad776..34a7f33 100644 --- a/release-goosed-remount-guard.test.mjs +++ b/release-goosed-remount-guard.test.mjs @@ -6,6 +6,10 @@ const releaseScript = fs.readFileSync( new URL('./scripts/release-portal-runtime-prod.sh', import.meta.url), 'utf8', ); +const mindSpaceReleaseScript = fs.readFileSync( + new URL('./scripts/release-mindspace-service-prod.sh', import.meta.url), + 'utf8', +); test('103 release remounts goosed after swapping the live directory', () => { assert.match(releaseScript, /remount_goosed_after_live_swap\(\)/); @@ -23,3 +27,43 @@ test('103 release remounts goosed after swapping the live directory', () => { assert.ok(remountIndex > swapIndex, 'goosed remount must happen after the live swap'); assert.ok(portalStartIndex > remountIndex, 'Portal must start only after goosed remount succeeds'); }); + +test('MindSpace release preserves runtime data and returns it during rollback', () => { + assert.match(mindSpaceReleaseScript, /PERSISTED_DATA_MOVED=0/); + assert.match(mindSpaceReleaseScript, /PERSISTED_USERS_MOVED=0/); + assert.match(mindSpaceReleaseScript, /mv "\$\{OLD_APP_ARCHIVE\}\/data" "\$\{APP_DIR\}\/data"/); + assert.match(mindSpaceReleaseScript, /mv "\$\{OLD_APP_ARCHIVE\}\/users" "\$\{APP_DIR\}\/users"/); + assert.match(mindSpaceReleaseScript, /mv "\$\{APP_DIR\}\/data" "\$\{OLD_APP_ARCHIVE\}\/data"/); + assert.match(mindSpaceReleaseScript, /mv "\$\{APP_DIR\}\/users" "\$\{OLD_APP_ARCHIVE\}\/users"/); +}); + +test('MindSpace release waits for the old listener and verifies the new launchd job', () => { + const bootoutIndex = mindSpaceReleaseScript.indexOf( + 'launchctl bootout "${LAUNCHD_GUI}/${SERVICE_LABEL}"', + ); + const listenerWaitIndex = mindSpaceReleaseScript.indexOf('lsof -nP -iTCP:8082', bootoutIndex); + const swapIndex = mindSpaceReleaseScript.indexOf('mv "${NEW_DIR}" "${APP_DIR}"'); + const bootstrapIndex = mindSpaceReleaseScript.indexOf( + 'launchctl bootstrap "${LAUNCHD_GUI}" "${SERVICE_PLIST}"', + swapIndex, + ); + const portalRestartIndex = mindSpaceReleaseScript.indexOf( + 'launchctl kickstart -k "${LAUNCHD_GUI}/${PORTAL_LABEL}"', + bootstrapIndex, + ); + const finalServiceCheckIndex = mindSpaceReleaseScript.indexOf('service_is_running', portalRestartIndex); + + assert.ok(bootoutIndex >= 0, 'old MindSpace launchd job must be stopped'); + assert.ok(listenerWaitIndex > bootoutIndex, 'release must wait for the old 8082 listener to exit'); + assert.ok(swapIndex > listenerWaitIndex, 'runtime swap must happen after the old listener exits'); + assert.ok(bootstrapIndex > swapIndex, 'new launchd job must start after the runtime swap'); + assert.doesNotMatch( + mindSpaceReleaseScript.slice(bootstrapIndex, bootstrapIndex + 120), + /\|\| true/, + 'launchctl bootstrap failures must not be ignored', + ); + assert.ok( + finalServiceCheckIndex > portalRestartIndex, + 'MindSpace launchd state must be rechecked after Portal restarts', + ); +}); diff --git a/scripts/release-mindspace-service-prod.sh b/scripts/release-mindspace-service-prod.sh index 1fedbdb..60bd9f1 100644 --- a/scripts/release-mindspace-service-prod.sh +++ b/scripts/release-mindspace-service-prod.sh @@ -203,6 +203,10 @@ say() { printf '\n[remote %s] %s\n' "$(date +%H:%M:%S)" "$*" } +service_is_running() { + launchctl print "${LAUNCHD_GUI}/${SERVICE_LABEL}" 2>/dev/null | grep -q 'state = running' +} + rollback() { set +e say "rollback: restoring previous state" @@ -314,6 +318,16 @@ EOF say "切换 MindSpace service live 目录" launchctl bootout "${LAUNCHD_GUI}/${SERVICE_LABEL}" >/dev/null 2>&1 || true +for _ in $(seq 1 60); do + if ! lsof -nP -iTCP:8082 -sTCP:LISTEN >/dev/null 2>&1; then + break + fi + sleep 1 +done +if lsof -nP -iTCP:8082 -sTCP:LISTEN >/dev/null 2>&1; then + echo "旧 MindSpace service 未在超时内退出,拒绝切换 runtime" >&2 + exit 1 +fi rm -rf "${OLD_APP_ARCHIVE}" if [[ -d "${APP_DIR}" ]]; then mv "${APP_DIR}" "${OLD_APP_ARCHIVE}" @@ -335,19 +349,20 @@ fi say "启动 MindSpace service" mkdir -p "${APP_DIR}/data/mindspace" "${APP_DIR}/users" -launchctl bootstrap "${LAUNCHD_GUI}" "${SERVICE_PLIST}" >/dev/null 2>&1 || true -launchctl enable "${LAUNCHD_GUI}/${SERVICE_LABEL}" >/dev/null 2>&1 || true -launchctl kickstart -k "${LAUNCHD_GUI}/${SERVICE_LABEL}" >/dev/null 2>&1 || true +launchctl bootstrap "${LAUNCHD_GUI}" "${SERVICE_PLIST}" >/dev/null SERVICE_STARTED=1 +launchctl enable "${LAUNCHD_GUI}/${SERVICE_LABEL}" >/dev/null +launchctl kickstart -k "${LAUNCHD_GUI}/${SERVICE_LABEL}" >/dev/null for _ in $(seq 1 30); do code="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8082/health || true)" - if [[ "${code}" == "200" ]]; then + if [[ "${code}" == "200" ]] && service_is_running; then break fi sleep 1 done [[ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8082/health || true)" == "200" ]] +service_is_running [[ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8082/mindspace/v1/contract || true)" == "200" ]] set_env() { @@ -395,6 +410,9 @@ for _ in $(seq 1 30); do sleep 1 done [[ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8081/api/status || true)" == "200" ]] +sleep 3 +[[ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8082/health || true)" == "200" ]] +service_is_running trap - ERR @@ -403,7 +421,7 @@ printf 'release_id=%s\n' "${RELEASE_ID}" printf 'service_root=%s\n' "${APP_DIR}" printf 'service_backup=%s\n' "${APP_BACKUP}" printf 'env_backup=%s\n' "${ENV_BACKUP}" -printf 'service_health=%s\n' "$(curl -s http://127.0.0.1:8082/health)" +printf 'service_health=%s\n' "$(curl -fsS http://127.0.0.1:8082/health)" REMOTE say "发布完成"