@clearance/management 0.1.4 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,394 @@
1
+ #!/usr/bin/env bash
2
+ # Verify or execute a deterministic rollback from an upgrade plan.
3
+ set -Eeuo pipefail
4
+
5
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
6
+ # shellcheck source=lib/ops-common.sh
7
+ source "$ROOT/scripts/lib/ops-common.sh"
8
+ umask 077
9
+
10
+ usage() {
11
+ cat <<'EOF'
12
+ Usage: upgrade-rollback.sh --plan PLAN_ID_OR_PATH [--dir DIR]
13
+ upgrade-rollback.sh --plan PLAN_ID_OR_PATH --restore-active \
14
+ --confirm RESTORE_ACTIVE:<plan-id>:<database> [--backup-dir DIR]
15
+
16
+ Default behavior verifies the immutable rollback backup in an isolated database
17
+ and writes a drill receipt. --restore-active performs the real active-database
18
+ rollback only with the exact confirmation token. The active path:
19
+ 1. serializes operators with a held Postgres advisory lock
20
+ 2. creates and restore-verifies a pre-restore safety backup
21
+ 3. restores and verifies the rollback backup in a staging database
22
+ 4. drains connections and swaps database names
23
+ 5. verifies counts and application version while retaining the drained old database
24
+ 6. writes an owner-only rollback receipt and plan-state journal entry
25
+ EOF
26
+ }
27
+
28
+ PLAN_DIR="${CLEARANCE_UPGRADE_DIR:-$ROOT/.clearance/upgrades}"
29
+ BACKUP_DIR="${CLEARANCE_BACKUP_DIR:-$ROOT/.clearance/backups}"
30
+ PLAN_REF=""
31
+ RESTORE_ACTIVE=0
32
+ CONFIRM=""
33
+ while [[ $# -gt 0 ]]; do
34
+ case "$1" in
35
+ --plan) PLAN_REF="$2"; shift 2 ;;
36
+ --dir) PLAN_DIR="$2"; shift 2 ;;
37
+ --backup-dir) BACKUP_DIR="$2"; shift 2 ;;
38
+ --restore-active) RESTORE_ACTIVE=1; shift ;;
39
+ --confirm) CONFIRM="$2"; shift 2 ;;
40
+ -h|--help) usage; exit 0 ;;
41
+ *) die "unknown argument: $1" ;;
42
+ esac
43
+ done
44
+
45
+ [[ -n "$PLAN_REF" ]] || die "--plan is required"
46
+ if [[ -f "$PLAN_REF" ]]; then PLAN_PATH="$PLAN_REF"; else PLAN_PATH="$PLAN_DIR/${PLAN_REF}.plan.json"; fi
47
+ [[ -f "$PLAN_PATH" ]] || die "plan not found"
48
+ require_cmd node
49
+ require_cmd openssl
50
+ require_pg_client
51
+
52
+ PLAN_ID="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.planId)' "$PLAN_PATH")"
53
+ [[ "$PLAN_ID" =~ ^upg_[0-9TZ]+_[a-f0-9]+$ ]] || die "plan id is unsafe"
54
+ STATE_PATH="$PLAN_DIR/${PLAN_ID}.state.json"
55
+ [[ -f "$STATE_PATH" ]] || die "state missing"
56
+ EXPECTED_PLAN_SHA="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.planSha256||"")' "$STATE_PATH")"
57
+ [[ -n "$EXPECTED_PLAN_SHA" && "$(sha256_file "$PLAN_PATH")" == "$EXPECTED_PLAN_SHA" ]] \
58
+ || die "immutable plan checksum mismatch"
59
+
60
+ BACKUP_ID="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.backupId||(j.rollbackReference&&j.rollbackReference.backupId)||"")' "$STATE_PATH")"
61
+ BACKUP_DUMP="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.backupDump||(j.rollbackReference&&j.rollbackReference.dump)||"")' "$STATE_PATH")"
62
+ BACKUP_META="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.backupMeta||(j.rollbackReference&&j.rollbackReference.meta)||"")' "$STATE_PATH")"
63
+ BACKUP_EVIDENCE="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.backupEvidence||(j.rollbackReference&&j.rollbackReference.evidence)||"")' "$STATE_PATH")"
64
+ [[ -n "$BACKUP_ID" && -f "$BACKUP_DUMP" && -f "$BACKUP_META" && -f "$BACKUP_EVIDENCE" ]] \
65
+ || die "rollback reference is incomplete or its immutable artifacts are missing"
66
+
67
+ URL="$(resolve_database_url)"
68
+ require_database_url "$URL"
69
+ export DATABASE_URL="$URL"
70
+ ACTIVE_DB="$(db_name_from_url "$URL")"
71
+ assert_safe_ident "$ACTIVE_DB" "active database"
72
+ ADMIN_URL="$(admin_url_from_url "$URL")"
73
+ SOURCE_DB="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.sourceDatabase||"")' "$BACKUP_META")"
74
+ [[ "$SOURCE_DB" == "$ACTIVE_DB" ]] || die "rollback backup source database does not match active database"
75
+
76
+ bash "$ROOT/scripts/backup-verify.sh" --dump "$BACKUP_DUMP" --meta "$BACKUP_META" --evidence "$BACKUP_EVIDENCE" >/dev/null
77
+ bash "$ROOT/scripts/backup-restore-verify.sh" --dump "$BACKUP_DUMP" --meta "$BACKUP_META" >/dev/null
78
+
79
+ RECEIPT_DIR="$PLAN_DIR/rollbacks"
80
+ mkdir -p "$RECEIPT_DIR"
81
+ chmod 700 "$RECEIPT_DIR"
82
+
83
+ if [[ "$RESTORE_ACTIVE" -eq 0 ]]; then
84
+ RECEIPT="$RECEIPT_DIR/${PLAN_ID}.rollback-drill.json"
85
+ RECEIPT_TMP="$(mktemp "$RECEIPT_DIR/.${PLAN_ID}.rollback-drill.XXXXXX")"
86
+ cat >"$RECEIPT_TMP" <<EOF
87
+ {
88
+ "planId": $(json_escape "$PLAN_ID"),
89
+ "verifiedAt": $(json_escape "$(iso_now)"),
90
+ "mode": "isolated_verify_only",
91
+ "backupId": $(json_escape "$BACKUP_ID"),
92
+ "backupDumpSha256": $(json_escape "$(sha256_file "$BACKUP_DUMP")"),
93
+ "backupMetaSha256": $(json_escape "$(sha256_file "$BACKUP_META")"),
94
+ "backupEvidenceSha256": $(json_escape "$(sha256_file "$BACKUP_EVIDENCE")"),
95
+ "activeDatabaseUntouched": true
96
+ }
97
+ EOF
98
+ chmod 400 "$RECEIPT_TMP"
99
+ mv -f "$RECEIPT_TMP" "$RECEIPT"
100
+ printf 'upgrade-rollback: DRILL OK; active environment unchanged plan=%s backup=%s\n' "$PLAN_ID" "$BACKUP_ID" >&2
101
+ printf 'ROLLBACK_RECEIPT=%s\n' "$RECEIPT"
102
+ printf 'ROLLBACK_DRILL_OK=1\n'
103
+ exit 0
104
+ fi
105
+
106
+ STATUS="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.status||"")' "$STATE_PATH")"
107
+ [[ "$STATUS" == "applied" || "$STATUS" == "verified" ]] \
108
+ || die "active rollback requires plan state applied or verified (found $STATUS)"
109
+ EXPECTED_CONFIRM="RESTORE_ACTIVE:${PLAN_ID}:${ACTIVE_DB}"
110
+ [[ "$CONFIRM" == "$EXPECTED_CONFIRM" ]] \
111
+ || die "active rollback confirmation mismatch; required --confirm $EXPECTED_CONFIRM"
112
+ TARGET_VERSION="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.targetVersion)' "$PLAN_PATH")"
113
+ require_application_release "$URL" "$TARGET_VERSION"
114
+
115
+ # Keep one session open while the operation runs. pg_try_advisory_lock is
116
+ # session-scoped, so a second rollback operator fails before taking a backup.
117
+ # Named pipes keep this compatible with macOS Bash 3.2 (no coproc dependency).
118
+ LOCK_KEY=7176324950072026
119
+ LOCK_PIPE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/clearance-rollback-lock.XXXXXX")"
120
+ LOCK_IN="$LOCK_PIPE_DIR/in"
121
+ LOCK_OUT="$LOCK_PIPE_DIR/out"
122
+ mkfifo "$LOCK_IN" "$LOCK_OUT"
123
+ psql --no-psqlrc -qAt -v ON_ERROR_STOP=1 "$ADMIN_URL" <"$LOCK_IN" >"$LOCK_OUT" &
124
+ LOCK_PID=$!
125
+ exec 8>"$LOCK_IN"
126
+ exec 9<"$LOCK_OUT"
127
+ printf 'SELECT pg_try_advisory_lock(%s);\n' "$LOCK_KEY" >&8
128
+ if ! IFS= read -r LOCKED <&9; then
129
+ exec 8>&-
130
+ exec 9<&-
131
+ wait "$LOCK_PID" 2>/dev/null || true
132
+ find "$LOCK_PIPE_DIR" -type p -delete 2>/dev/null || true
133
+ rmdir "$LOCK_PIPE_DIR" 2>/dev/null || true
134
+ die "could not acquire rollback advisory lock"
135
+ fi
136
+ [[ "$LOCKED" == "t" ]] || {
137
+ printf '\\q\n' >&8 2>/dev/null || true
138
+ exec 8>&-
139
+ exec 9<&-
140
+ wait "$LOCK_PID" 2>/dev/null || true
141
+ find "$LOCK_PIPE_DIR" -type p -delete 2>/dev/null || true
142
+ rmdir "$LOCK_PIPE_DIR" 2>/dev/null || true
143
+ die "another active-database recovery operation holds the advisory lock"
144
+ }
145
+
146
+ STAGE_DB="clr_rb_$(openssl rand -hex 4)"
147
+ OLD_DB="clr_old_$(openssl rand -hex 4)"
148
+ assert_safe_ident "$STAGE_DB" "rollback staging database"
149
+ assert_safe_ident "$OLD_DB" "pre-rollback database"
150
+ STAGE_CREATED=0
151
+ SWAPPED=0
152
+ LOCK_RELEASED=0
153
+ FAILED_DB=""
154
+
155
+ database_exists() {
156
+ local name="$1"
157
+ [[ "$(psql_q "$ADMIN_URL" -At -c "SELECT count(*) FROM pg_database WHERE datname='${name}';" 2>/dev/null || printf '0')" == "1" ]]
158
+ }
159
+
160
+ database_oid() {
161
+ local name="$1"
162
+ psql_q "$ADMIN_URL" -At -c "SELECT oid::text FROM pg_database WHERE datname='${name}';" 2>/dev/null | head -n1
163
+ }
164
+
165
+ journal_rollback_failure() {
166
+ local event="$1"
167
+ local recovered="$2"
168
+ local detail="$3"
169
+ ROLLBACK_EVENT="$event" ROLLBACK_RECOVERED="$recovered" ROLLBACK_DETAIL="$detail" \
170
+ ROLLBACK_ACTIVE_DB="$ACTIVE_DB" ROLLBACK_STAGE_DB="$STAGE_DB" \
171
+ ROLLBACK_OLD_DB="$OLD_DB" ROLLBACK_FAILED_DB="$FAILED_DB" node -e '
172
+ const fs=require("fs");
173
+ const p=process.argv[1];
174
+ const j=JSON.parse(fs.readFileSync(p,"utf8"));
175
+ const at=new Date().toISOString().replace(/\.\d{3}Z$/,"Z");
176
+ const exists=(v)=>v ? v : null;
177
+ j.status="rollback_failed";
178
+ j.updatedAt=at;
179
+ j.rollbackFailure={
180
+ at,
181
+ event:process.env.ROLLBACK_EVENT,
182
+ recovered:process.env.ROLLBACK_RECOVERED==="true",
183
+ detail:process.env.ROLLBACK_DETAIL,
184
+ databases:{
185
+ active:exists(process.env.ROLLBACK_ACTIVE_DB),
186
+ staging:exists(process.env.ROLLBACK_STAGE_DB),
187
+ preRollback:exists(process.env.ROLLBACK_OLD_DB),
188
+ failedRestore:exists(process.env.ROLLBACK_FAILED_DB)
189
+ }
190
+ };
191
+ j.applyJournal=j.applyJournal||[];
192
+ j.applyJournal.push({at,event:"active_database_rollback_failed",...j.rollbackFailure});
193
+ fs.writeFileSync(p,JSON.stringify(j,null,2)+"\n");
194
+ ' "$STATE_PATH"
195
+ }
196
+
197
+ cleanup() {
198
+ local ec=$?
199
+ # Only clean an unmistakably isolated staging DB. If the active name is
200
+ # absent or the old name exists, preserve every database for recovery.
201
+ if [[ "$STAGE_CREATED" -eq 1 && "$SWAPPED" -eq 0 ]] \
202
+ && database_exists "$ACTIVE_DB" && ! database_exists "$OLD_DB"; then
203
+ psql_q "$ADMIN_URL" -c "DROP DATABASE IF EXISTS \"${STAGE_DB}\" WITH (FORCE);" >/dev/null 2>&1 || true
204
+ fi
205
+ if [[ "$LOCK_RELEASED" -eq 0 ]]; then
206
+ printf 'SELECT pg_advisory_unlock(%s);\n\\q\n' "$LOCK_KEY" >&8 2>/dev/null || true
207
+ exec 8>&-
208
+ exec 9<&-
209
+ wait "$LOCK_PID" 2>/dev/null || true
210
+ fi
211
+ find "$LOCK_PIPE_DIR" -type p -delete 2>/dev/null || true
212
+ rmdir "$LOCK_PIPE_DIR" 2>/dev/null || true
213
+ exit "$ec"
214
+ }
215
+ trap cleanup EXIT INT TERM
216
+
217
+ # Preserve the current target-version database before any connection drain.
218
+ SAFETY_OUT="$(bash "$ROOT/scripts/backup-create.sh" --dir "$BACKUP_DIR")"
219
+ SAFETY_ID="$(printf '%s\n' "$SAFETY_OUT" | sed -n 's/^BACKUP_ID=//p' | tail -1)"
220
+ SAFETY_DUMP="$(printf '%s\n' "$SAFETY_OUT" | sed -n 's/^BACKUP_DUMP=//p' | tail -1)"
221
+ SAFETY_META="$(printf '%s\n' "$SAFETY_OUT" | sed -n 's/^BACKUP_META=//p' | tail -1)"
222
+ SAFETY_EVIDENCE="$(printf '%s\n' "$SAFETY_OUT" | sed -n 's/^BACKUP_EVIDENCE=//p' | tail -1)"
223
+ [[ -n "$SAFETY_ID" && -f "$SAFETY_DUMP" && -f "$SAFETY_META" && -f "$SAFETY_EVIDENCE" ]] \
224
+ || die "pre-restore safety backup is incomplete"
225
+ bash "$ROOT/scripts/backup-verify.sh" --id "$SAFETY_ID" --dir "$BACKUP_DIR" >/dev/null
226
+ bash "$ROOT/scripts/backup-restore-verify.sh" --id "$SAFETY_ID" --dir "$BACKUP_DIR" >/dev/null
227
+ if [[ "${CLEARANCE_OPS_TESTING:-0}" == "1" ]]; then
228
+ if [[ -n "${CLEARANCE_ROLLBACK_TEST_AFTER_SAFETY_READY_FILE:-}" ]]; then
229
+ : >"$CLEARANCE_ROLLBACK_TEST_AFTER_SAFETY_READY_FILE"
230
+ fi
231
+ delay="${CLEARANCE_ROLLBACK_TEST_DELAY_AFTER_SAFETY_SECONDS:-0}"
232
+ [[ "$delay" =~ ^[0-9]+([.][0-9]+)?$ ]] || die "invalid rollback testing delay"
233
+ if [[ "$delay" != "0" ]]; then sleep "$delay"; fi
234
+ fi
235
+
236
+ # Preserve database owner, encoding, and locale in the staging database.
237
+ CREATE_STAGE_SQL="$(psql_q "$ADMIN_URL" -At -c "
238
+ SELECT format('CREATE DATABASE %I WITH OWNER %I TEMPLATE template0 ENCODING %L LC_COLLATE %L LC_CTYPE %L',
239
+ '${STAGE_DB}', pg_get_userbyid(datdba), pg_encoding_to_char(encoding), datcollate, datctype)
240
+ FROM pg_database WHERE datname = '${ACTIVE_DB}';
241
+ ")"
242
+ [[ -n "$CREATE_STAGE_SQL" ]] || die "could not capture active database creation contract"
243
+ psql_q "$ADMIN_URL" -c "$CREATE_STAGE_SQL" >/dev/null
244
+ STAGE_CREATED=1
245
+ STAGE_URL="$(url_with_db "$URL" "$STAGE_DB")"
246
+ psql_restore_file "$STAGE_URL" "$BACKUP_DUMP"
247
+ STAGE_EVIDENCE="$(mktemp "${TMPDIR:-/tmp}/clearance-active-restore.XXXXXX")"
248
+ collect_db_evidence "$STAGE_URL" "$STAGE_EVIDENCE"
249
+ compare_backup_evidence "$BACKUP_META" "$STAGE_EVIDENCE" >/dev/null
250
+ EXPECTED_BACKUP_VERSION="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.applicationVersion||"")' "$BACKUP_META")"
251
+ [[ -n "$EXPECTED_BACKUP_VERSION" ]] || die "rollback backup lacks an application version contract"
252
+ require_application_release "$STAGE_URL" "$EXPECTED_BACKUP_VERSION"
253
+ find "$STAGE_EVIDENCE" -type f -delete 2>/dev/null || true
254
+
255
+ # The restored staging DB is complete before traffic is drained. Rename swap
256
+ # keeps the destructive window short and preserves the old DB until live proof.
257
+ OLD_OID="$(database_oid "$ACTIVE_DB")"
258
+ [[ -n "$OLD_OID" ]] || die "could not capture original active database identity"
259
+ DRAIN_RENAME_OK=1
260
+ psql_q "$ADMIN_URL" -c "ALTER DATABASE \"${ACTIVE_DB}\" WITH ALLOW_CONNECTIONS false;" >/dev/null \
261
+ || DRAIN_RENAME_OK=0
262
+ if [[ "$DRAIN_RENAME_OK" -eq 1 ]]; then
263
+ psql_q "$ADMIN_URL" -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='${ACTIVE_DB}' AND pid<>pg_backend_pid();" >/dev/null \
264
+ || DRAIN_RENAME_OK=0
265
+ fi
266
+ if [[ "${CLEARANCE_OPS_TESTING:-0}" == "1" && "${CLEARANCE_ROLLBACK_TEST_FAIL_INITIAL_RENAME:-0}" == "1" ]]; then
267
+ DRAIN_RENAME_OK=0
268
+ elif [[ "$DRAIN_RENAME_OK" -eq 1 ]]; then
269
+ psql_q "$ADMIN_URL" -c "ALTER DATABASE \"${ACTIVE_DB}\" RENAME TO \"${OLD_DB}\";" >/dev/null \
270
+ || DRAIN_RENAME_OK=0
271
+ fi
272
+ if [[ "$DRAIN_RENAME_OK" -ne 1 ]]; then
273
+ RECOVERED=false
274
+ if database_exists "$OLD_DB" && ! database_exists "$ACTIVE_DB"; then
275
+ psql_q "$ADMIN_URL" -c "ALTER DATABASE \"${OLD_DB}\" RENAME TO \"${ACTIVE_DB}\";" >/dev/null 2>&1 || true
276
+ fi
277
+ if database_exists "$ACTIVE_DB" \
278
+ && [[ "$(database_oid "$ACTIVE_DB")" == "$OLD_OID" ]] \
279
+ && psql_q "$ADMIN_URL" -c "ALTER DATABASE \"${ACTIVE_DB}\" WITH ALLOW_CONNECTIONS true;" >/dev/null 2>&1 \
280
+ && database_exists "$ACTIVE_DB" && ! database_exists "$OLD_DB"; then
281
+ RECOVERED=true
282
+ fi
283
+ journal_rollback_failure "active_database_drain_or_rename_failed" "$RECOVERED" \
284
+ "active database drain or initial rename failed; catalog state was preserved"
285
+ if [[ "$RECOVERED" == "true" ]]; then
286
+ die "active database drain or rename failed; original database was verified as connection-enabled; failure journal written"
287
+ fi
288
+ die "active database drain or rename failed and recovery could not be verified; all databases preserved; failure journal written"
289
+ fi
290
+ if ! psql_q "$ADMIN_URL" -c "ALTER DATABASE \"${STAGE_DB}\" RENAME TO \"${ACTIVE_DB}\";" >/dev/null; then
291
+ RECOVERED=false
292
+ if psql_q "$ADMIN_URL" -c "ALTER DATABASE \"${OLD_DB}\" RENAME TO \"${ACTIVE_DB}\";" >/dev/null 2>&1 \
293
+ && psql_q "$ADMIN_URL" -c "ALTER DATABASE \"${ACTIVE_DB}\" WITH ALLOW_CONNECTIONS true;" >/dev/null 2>&1 \
294
+ && database_exists "$ACTIVE_DB" && ! database_exists "$OLD_DB" \
295
+ && [[ "$(database_oid "$ACTIVE_DB")" == "$OLD_OID" ]]; then
296
+ RECOVERED=true
297
+ fi
298
+ journal_rollback_failure "staging_database_rename_failed" "$RECOVERED" \
299
+ "staging database could not be promoted; catalog state was preserved"
300
+ if [[ "$RECOVERED" == "true" ]]; then
301
+ die "database swap failed; original database was verified as reinstated; failure journal written"
302
+ fi
303
+ die "database swap failed and original database could not be verified as reinstated; all databases preserved; failure journal written"
304
+ fi
305
+ SWAPPED=1
306
+ STAGE_CREATED=0
307
+
308
+ LIVE_EVIDENCE="$(mktemp "${TMPDIR:-/tmp}/clearance-active-live.XXXXXX")"
309
+ LIVE_VERIFY_OK=1
310
+ collect_db_evidence "$URL" "$LIVE_EVIDENCE" || LIVE_VERIFY_OK=0
311
+ compare_backup_evidence "$BACKUP_META" "$LIVE_EVIDENCE" >/dev/null || LIVE_VERIFY_OK=0
312
+ LIVE_APP_VERSION="$(application_release_version "$URL")"
313
+ [[ "$LIVE_APP_VERSION" == "$EXPECTED_BACKUP_VERSION" ]] || LIVE_VERIFY_OK=0
314
+ if [[ "${CLEARANCE_OPS_TESTING:-0}" == "1" && "${CLEARANCE_ROLLBACK_TEST_FAIL_AFTER_SWAP:-0}" == "1" ]]; then
315
+ LIVE_VERIFY_OK=0
316
+ fi
317
+ if [[ "$LIVE_VERIFY_OK" -ne 1 ]]; then
318
+ # Reverse the swap while the untouched old database is still available.
319
+ FAILED_DB="clr_failed_$(openssl rand -hex 4)"
320
+ assert_safe_ident "$FAILED_DB" "failed restore database"
321
+ RECOVERED=false
322
+ REVERSAL_OK=1
323
+ psql_q "$ADMIN_URL" -c "ALTER DATABASE \"${ACTIVE_DB}\" WITH ALLOW_CONNECTIONS false;" >/dev/null 2>&1 || REVERSAL_OK=0
324
+ psql_q "$ADMIN_URL" -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='${ACTIVE_DB}' AND pid<>pg_backend_pid();" >/dev/null 2>&1 || REVERSAL_OK=0
325
+ psql_q "$ADMIN_URL" -c "ALTER DATABASE \"${ACTIVE_DB}\" RENAME TO \"${FAILED_DB}\";" >/dev/null 2>&1 || REVERSAL_OK=0
326
+ if [[ "${CLEARANCE_OPS_TESTING:-0}" == "1" && "${CLEARANCE_ROLLBACK_TEST_FAIL_REVERSAL:-0}" == "1" ]]; then
327
+ REVERSAL_OK=0
328
+ elif [[ "$REVERSAL_OK" -eq 1 ]]; then
329
+ psql_q "$ADMIN_URL" -c "ALTER DATABASE \"${OLD_DB}\" RENAME TO \"${ACTIVE_DB}\";" >/dev/null 2>&1 || REVERSAL_OK=0
330
+ psql_q "$ADMIN_URL" -c "ALTER DATABASE \"${ACTIVE_DB}\" WITH ALLOW_CONNECTIONS true;" >/dev/null 2>&1 || REVERSAL_OK=0
331
+ fi
332
+ if [[ "$REVERSAL_OK" -eq 1 ]] && database_exists "$ACTIVE_DB" && ! database_exists "$OLD_DB" \
333
+ && [[ "$(database_oid "$ACTIVE_DB")" == "$OLD_OID" ]]; then
334
+ RECOVERED=true
335
+ fi
336
+ journal_rollback_failure "post_restore_verification_failed" "$RECOVERED" \
337
+ "post-restore verification failed; databases were preserved for recovery"
338
+ if [[ "$RECOVERED" == "true" ]]; then
339
+ die "post-restore verification failed; original database was verified as reinstated; failure journal written"
340
+ fi
341
+ die "post-restore verification failed and reversal could not be verified; all databases preserved; failure journal written"
342
+ fi
343
+ find "$LIVE_EVIDENCE" -type f -delete 2>/dev/null || true
344
+
345
+ RECEIPT="$RECEIPT_DIR/${PLAN_ID}.rollback.json"
346
+ cat >"$RECEIPT" <<EOF
347
+ {
348
+ "planId": $(json_escape "$PLAN_ID"),
349
+ "rolledBackAt": $(json_escape "$(iso_now)"),
350
+ "mode": "active_database_restore",
351
+ "database": $(json_escape "$ACTIVE_DB"),
352
+ "fromVersion": $(json_escape "$TARGET_VERSION"),
353
+ "toVersion": $(json_escape "$EXPECTED_BACKUP_VERSION"),
354
+ "backupId": $(json_escape "$BACKUP_ID"),
355
+ "backupDumpSha256": $(json_escape "$(sha256_file "$BACKUP_DUMP")"),
356
+ "backupMetaSha256": $(json_escape "$(sha256_file "$BACKUP_META")"),
357
+ "backupEvidenceSha256": $(json_escape "$(sha256_file "$BACKUP_EVIDENCE")"),
358
+ "preRestoreSafetyBackupId": $(json_escape "$SAFETY_ID"),
359
+ "preRestoreSafetyDumpSha256": $(json_escape "$(sha256_file "$SAFETY_DUMP")"),
360
+ "preRestoreSafetyMetaSha256": $(json_escape "$(sha256_file "$SAFETY_META")"),
361
+ "preRestoreSafetyEvidenceSha256": $(json_escape "$(sha256_file "$SAFETY_EVIDENCE")"),
362
+ "preservedPreRollbackDatabase": $(json_escape "$OLD_DB"),
363
+ "preservedPreRollbackDatabaseOid": $(json_escape "$OLD_OID"),
364
+ "postSafetyWritesPreserved": true,
365
+ "advisoryLockKey": "$LOCK_KEY",
366
+ "postRestoreVerified": true
367
+ }
368
+ EOF
369
+ chmod 400 "$RECEIPT"
370
+
371
+ node -e '
372
+ const fs=require("fs");
373
+ const p=process.argv[1], receipt=process.argv[2], safety=process.argv[3], to=process.argv[4], preserved=process.argv[5];
374
+ const j=JSON.parse(fs.readFileSync(p,"utf8"));
375
+ const at=new Date().toISOString().replace(/\.\d{3}Z$/,"Z");
376
+ j.status="rolled_back";
377
+ j.updatedAt=at;
378
+ j.rollbackReceipt=receipt;
379
+ j.preRestoreSafetyBackupId=safety;
380
+ j.preservedPreRollbackDatabase=preserved;
381
+ j.applyJournal=j.applyJournal||[];
382
+ j.applyJournal.push({at,event:"active_database_rollback_completed",receipt,safetyBackupId:safety,toVersion:to,preservedPreRollbackDatabase:preserved});
383
+ fs.writeFileSync(p,JSON.stringify(j,null,2)+"\n");
384
+ ' "$STATE_PATH" "$RECEIPT" "$SAFETY_ID" "$EXPECTED_BACKUP_VERSION" "$OLD_DB"
385
+
386
+ printf 'SELECT pg_advisory_unlock(%s);\n\\q\n' "$LOCK_KEY" >&8
387
+ exec 8>&-
388
+ exec 9<&-
389
+ wait "$LOCK_PID" 2>/dev/null || true
390
+ LOCK_RELEASED=1
391
+ printf 'upgrade-rollback: ACTIVE RESTORE OK plan=%s database=%s backup=%s safety=%s\n' "$PLAN_ID" "$ACTIVE_DB" "$BACKUP_ID" "$SAFETY_ID" >&2
392
+ printf 'ROLLBACK_RECEIPT=%s\n' "$RECEIPT"
393
+ printf 'SAFETY_BACKUP_ID=%s\n' "$SAFETY_ID"
394
+ printf 'ACTIVE_ROLLBACK_OK=1\n'
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env bash
2
+ # Post-apply upgrade verification: plan state, backup reference, schema health, optional HTTP.
3
+ set -Eeuo pipefail
4
+
5
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
6
+ # shellcheck source=lib/ops-common.sh
7
+ source "$ROOT/scripts/lib/ops-common.sh"
8
+
9
+ usage() {
10
+ cat <<'EOF'
11
+ Usage: upgrade-verify.sh --plan PLAN_ID_OR_PATH [--dir DIR] [--health-url URL]
12
+ EOF
13
+ }
14
+
15
+ PLAN_DIR="${CLEARANCE_UPGRADE_DIR:-$ROOT/.clearance/upgrades}"
16
+ PLAN_REF=""
17
+ HEALTH_URL="${CLEARANCE_HEALTH_URL:-}"
18
+
19
+ while [[ $# -gt 0 ]]; do
20
+ case "$1" in
21
+ --plan) PLAN_REF="$2"; shift 2 ;;
22
+ --dir) PLAN_DIR="$2"; shift 2 ;;
23
+ --health-url) HEALTH_URL="$2"; shift 2 ;;
24
+ -h|--help) usage; exit 0 ;;
25
+ *) die "unknown argument: $1" ;;
26
+ esac
27
+ done
28
+
29
+ [[ -n "$PLAN_REF" ]] || die "--plan is required"
30
+ if [[ -f "$PLAN_REF" ]]; then
31
+ PLAN_PATH="$PLAN_REF"
32
+ else
33
+ PLAN_PATH="$PLAN_DIR/${PLAN_REF}.plan.json"
34
+ fi
35
+ [[ -f "$PLAN_PATH" ]] || die "plan not found"
36
+ require_cmd node
37
+
38
+ PLAN_ID="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.planId)' "$PLAN_PATH")"
39
+ STATE_PATH="$PLAN_DIR/${PLAN_ID}.state.json"
40
+ [[ -f "$STATE_PATH" ]] || die "state missing"
41
+
42
+ STATUS="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.status||"")' "$STATE_PATH")"
43
+ [[ "$STATUS" == "applied" || "$STATUS" == "verified" ]] || die "plan state is '$STATUS' (expected applied|verified)"
44
+
45
+ BACKUP_ID="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.backupId||"")' "$STATE_PATH")"
46
+ [[ -n "$BACKUP_ID" ]] || die "missing backupId rollback reference in state"
47
+ BACKUP_META="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.backupMeta||"")' "$STATE_PATH")"
48
+ [[ -f "$BACKUP_META" ]] || die "backup meta missing: $BACKUP_META"
49
+ bash "$ROOT/scripts/backup-verify.sh" --dump "$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.backupDump)' "$STATE_PATH")" --meta "$BACKUP_META" >/dev/null
50
+
51
+ # Schema still queryable
52
+ if [[ -n "${DATABASE_URL:-}" ]]; then
53
+ require_pg_client
54
+ URL="$(resolve_database_url)"
55
+ psql_q "$URL" -c 'SELECT 1' >/dev/null || die "post-apply database health query failed"
56
+ TARGET="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.targetVersion)' "$PLAN_PATH")"
57
+ require_application_release "$URL" "$TARGET"
58
+ assert_safe_version "$TARGET" "target version"
59
+ LEDGER_PLAN="$(psql_q "$URL" -At -c "SELECT plan_id FROM clearance_schema_migrations WHERE version = '${TARGET}'")"
60
+ [[ "$LEDGER_PLAN" == "$PLAN_ID" ]] || die "migration ledger missing plan/version transition"
61
+ # Fingerprint may change after real migrations; ensure DB still has public schema access
62
+ EV="$(mktemp "${TMPDIR:-/tmp}/clearance-post-ev.XXXXXX")"
63
+ collect_db_evidence "$URL" "$EV"
64
+ [[ -s "$EV" ]] || die "failed to collect post-apply schema evidence"
65
+ rm -f "$EV"
66
+ printf 'upgrade-verify: database evidence collected\n' >&2
67
+ fi
68
+
69
+ # Optional HTTP health
70
+ if [[ -n "$HEALTH_URL" ]]; then
71
+ require_cmd curl
72
+ code="$(curl -sS -o /dev/null -w '%{http_code}' "$HEALTH_URL" || true)"
73
+ [[ "$code" == "200" ]] || die "health URL $HEALTH_URL returned HTTP $code"
74
+ printf 'upgrade-verify: health URL OK\n' >&2
75
+ fi
76
+
77
+ # Applied marker exists
78
+ MARKER="$PLAN_DIR/markers/${PLAN_ID}.applied"
79
+ [[ -f "$MARKER" ]] || die "apply marker missing"
80
+
81
+ TARGET="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.targetVersion)' "$PLAN_PATH")"
82
+ MARKER_TO="$(node -e 'const fs=require("fs"); const j=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); process.stdout.write(j.toVersion||"")' "$MARKER")"
83
+ [[ "$MARKER_TO" == "$TARGET" ]] || die "marker version mismatch"
84
+
85
+ node -e '
86
+ const fs=require("fs");
87
+ const p=process.argv[1];
88
+ const j=JSON.parse(fs.readFileSync(p,"utf8"));
89
+ const at=new Date().toISOString().replace(/\.\d{3}Z$/,"Z");
90
+ j.status="verified";
91
+ j.updatedAt=at;
92
+ j.applyJournal=j.applyJournal||[];
93
+ j.applyJournal.push({at, event:"verified"});
94
+ fs.writeFileSync(p, JSON.stringify(j,null,2)+"\n");
95
+ ' "$STATE_PATH"
96
+
97
+ printf 'upgrade-verify: OK plan=%s target=%s\n' "$PLAN_ID" "$TARGET"
98
+ printf 'VERIFY_OK=1\n'