@clearance/cli 0.1.4
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.
- package/LICENSE +29 -0
- package/LICENSE.md +20 -0
- package/dist/api-client.d.ts +18 -0
- package/dist/api-client.js +88 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2494 -0
- package/dist/operator-auth.d.ts +28 -0
- package/dist/operator-auth.js +310 -0
- package/dist/ops/deploy/compose/docker-compose.production.yml +129 -0
- package/dist/ops/deploy/upgrades/README.md +24 -0
- package/dist/ops/deploy/upgrades/steps/0.2.0/apply.sh +62 -0
- package/dist/ops/scripts/backup-create.sh +197 -0
- package/dist/ops/scripts/backup-restore-verify.sh +129 -0
- package/dist/ops/scripts/backup-verify.sh +121 -0
- package/dist/ops/scripts/lib/ops-common.sh +375 -0
- package/dist/ops/scripts/scim-legacy-preflight.sh +103 -0
- package/dist/ops/scripts/upgrade-apply.sh +168 -0
- package/dist/ops/scripts/upgrade-plan.sh +129 -0
- package/dist/ops/scripts/upgrade-preflight.sh +136 -0
- package/dist/ops/scripts/upgrade-rollback.sh +394 -0
- package/dist/ops/scripts/upgrade-verify.sh +98 -0
- package/dist/ops/scripts/validate-production-env.sh +232 -0
- package/dist/output.d.ts +19 -0
- package/dist/output.js +52 -0
- package/dist/remote-dispatch.d.ts +10 -0
- package/dist/remote-dispatch.js +232 -0
- package/dist/store.d.ts +9 -0
- package/dist/store.js +43 -0
- package/dist/upgrade.d.ts +114 -0
- package/dist/upgrade.js +262 -0
- package/package.json +38 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Create an immutable upgrade plan with reference evidence and required backup slot.
|
|
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-plan.sh --target VERSION [--dir DIR]
|
|
12
|
+
|
|
13
|
+
Writes an immutable plan JSON under DIR. Refuses to overwrite an existing plan id.
|
|
14
|
+
Captures current release reference, schema evidence fingerprint, and preflight gates.
|
|
15
|
+
EOF
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
PLAN_DIR="${CLEARANCE_UPGRADE_DIR:-$ROOT/.clearance/upgrades}"
|
|
19
|
+
TARGET=""
|
|
20
|
+
CURRENT="${CLEARANCE_RELEASE_VERSION:-}"
|
|
21
|
+
|
|
22
|
+
while [[ $# -gt 0 ]]; do
|
|
23
|
+
case "$1" in
|
|
24
|
+
--target) TARGET="$2"; shift 2 ;;
|
|
25
|
+
--current) CURRENT="$2"; shift 2 ;;
|
|
26
|
+
--dir) PLAN_DIR="$2"; shift 2 ;;
|
|
27
|
+
-h|--help) usage; exit 0 ;;
|
|
28
|
+
*) die "unknown argument: $1" ;;
|
|
29
|
+
esac
|
|
30
|
+
done
|
|
31
|
+
|
|
32
|
+
[[ -n "$TARGET" ]] || die "--target VERSION is required"
|
|
33
|
+
assert_safe_version "$TARGET" "target version"
|
|
34
|
+
require_cmd node
|
|
35
|
+
require_cmd openssl
|
|
36
|
+
|
|
37
|
+
mkdir -p "$PLAN_DIR"
|
|
38
|
+
PLAN_ID="upg_$(date -u +%Y%m%dT%H%M%SZ)_$(openssl rand -hex 3)"
|
|
39
|
+
PLAN_PATH="$PLAN_DIR/${PLAN_ID}.plan.json"
|
|
40
|
+
[[ ! -f "$PLAN_PATH" ]] || die "plan path already exists (immutable): $PLAN_PATH"
|
|
41
|
+
|
|
42
|
+
URL=""
|
|
43
|
+
EVIDENCE="{}"
|
|
44
|
+
SOURCE_DB=""
|
|
45
|
+
if [[ -n "${DATABASE_URL:-}" ]]; then
|
|
46
|
+
require_pg_client
|
|
47
|
+
URL="$(resolve_database_url)"
|
|
48
|
+
require_database_url "$URL"
|
|
49
|
+
SOURCE_DB="$(db_name_from_url "$URL")"
|
|
50
|
+
APP_RELEASE="$(application_release_version "$URL")"
|
|
51
|
+
[[ -n "$APP_RELEASE" ]] || die "database is missing the Clearance application release contract"
|
|
52
|
+
if [[ -z "$CURRENT" ]]; then
|
|
53
|
+
CURRENT="$APP_RELEASE"
|
|
54
|
+
elif [[ "$CURRENT" != "$APP_RELEASE" ]]; then
|
|
55
|
+
die "--current $CURRENT does not match application release $APP_RELEASE"
|
|
56
|
+
fi
|
|
57
|
+
EV_FILE="$(mktemp "${TMPDIR:-/tmp}/clearance-upg-ev.XXXXXX")"
|
|
58
|
+
collect_db_evidence "$URL" "$EV_FILE"
|
|
59
|
+
EVIDENCE="$(cat "$EV_FILE")"
|
|
60
|
+
FINGERPRINT="$(schema_fingerprint_from_evidence "$EV_FILE")"
|
|
61
|
+
rm -f "$EV_FILE"
|
|
62
|
+
else
|
|
63
|
+
FINGERPRINT="no-database-url"
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
if [[ -z "$CURRENT" ]]; then
|
|
67
|
+
CURRENT="$(node -e 'process.stdout.write(require(process.argv[1]).version)' "$ROOT/packages/clearance-api/package.json")"
|
|
68
|
+
fi
|
|
69
|
+
assert_safe_version "$CURRENT" "current version"
|
|
70
|
+
|
|
71
|
+
CREATED="$(iso_now)"
|
|
72
|
+
cat >"$PLAN_PATH" <<EOF
|
|
73
|
+
{
|
|
74
|
+
"planId": $(json_escape "$PLAN_ID"),
|
|
75
|
+
"immutable": true,
|
|
76
|
+
"createdAt": $(json_escape "$CREATED"),
|
|
77
|
+
"currentVersion": $(json_escape "$CURRENT"),
|
|
78
|
+
"targetVersion": $(json_escape "$TARGET"),
|
|
79
|
+
"sourceDatabase": $(json_escape "${SOURCE_DB}"),
|
|
80
|
+
"schemaFingerprintSha256": $(json_escape "$FINGERPRINT"),
|
|
81
|
+
"schemaEvidence": $EVIDENCE,
|
|
82
|
+
"applicationContract": {
|
|
83
|
+
"snapshotTable": "clearance_management_snapshot",
|
|
84
|
+
"releaseVersion": $(json_escape "$CURRENT"),
|
|
85
|
+
"migrationLedger": "clearance_schema_migrations"
|
|
86
|
+
},
|
|
87
|
+
"status": "planned",
|
|
88
|
+
"requiredGates": [
|
|
89
|
+
"preflight",
|
|
90
|
+
"verified_backup",
|
|
91
|
+
"apply",
|
|
92
|
+
"post_verify"
|
|
93
|
+
],
|
|
94
|
+
"backupId": null,
|
|
95
|
+
"backupDump": null,
|
|
96
|
+
"backupMeta": null,
|
|
97
|
+
"backupEvidence": null,
|
|
98
|
+
"rollbackReference": null,
|
|
99
|
+
"applyJournal": [],
|
|
100
|
+
"tool": "scripts/upgrade-plan.sh"
|
|
101
|
+
}
|
|
102
|
+
EOF
|
|
103
|
+
|
|
104
|
+
# File permissions: plan is reference material
|
|
105
|
+
chmod a-w "$PLAN_PATH" 2>/dev/null || true
|
|
106
|
+
PLAN_SHA256="$(sha256_file "$PLAN_PATH")"
|
|
107
|
+
|
|
108
|
+
# Writable side-car for status transitions (plan body stays immutable)
|
|
109
|
+
STATE_PATH="$PLAN_DIR/${PLAN_ID}.state.json"
|
|
110
|
+
cat >"$STATE_PATH" <<EOF
|
|
111
|
+
{
|
|
112
|
+
"planId": $(json_escape "$PLAN_ID"),
|
|
113
|
+
"planPath": $(json_escape "$PLAN_PATH"),
|
|
114
|
+
"planSha256": $(json_escape "$PLAN_SHA256"),
|
|
115
|
+
"status": "planned",
|
|
116
|
+
"updatedAt": $(json_escape "$CREATED"),
|
|
117
|
+
"backupId": null,
|
|
118
|
+
"backupDump": null,
|
|
119
|
+
"backupMeta": null,
|
|
120
|
+
"backupEvidence": null,
|
|
121
|
+
"rollbackReference": null,
|
|
122
|
+
"applyJournal": []
|
|
123
|
+
}
|
|
124
|
+
EOF
|
|
125
|
+
|
|
126
|
+
printf 'upgrade-plan: wrote immutable plan %s\n' "$PLAN_PATH" >&2
|
|
127
|
+
printf 'PLAN_ID=%s\n' "$PLAN_ID"
|
|
128
|
+
printf 'PLAN_PATH=%s\n' "$PLAN_PATH"
|
|
129
|
+
printf 'STATE_PATH=%s\n' "$STATE_PATH"
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Preflight checks for an upgrade plan. Fail closed.
|
|
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-preflight.sh --plan PLAN_ID_OR_PATH [--dir DIR]
|
|
12
|
+
EOF
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
PLAN_DIR="${CLEARANCE_UPGRADE_DIR:-$ROOT/.clearance/upgrades}"
|
|
16
|
+
PLAN_REF=""
|
|
17
|
+
|
|
18
|
+
while [[ $# -gt 0 ]]; do
|
|
19
|
+
case "$1" in
|
|
20
|
+
--plan) PLAN_REF="$2"; shift 2 ;;
|
|
21
|
+
--dir) PLAN_DIR="$2"; shift 2 ;;
|
|
22
|
+
-h|--help) usage; exit 0 ;;
|
|
23
|
+
*) die "unknown argument: $1" ;;
|
|
24
|
+
esac
|
|
25
|
+
done
|
|
26
|
+
|
|
27
|
+
[[ -n "$PLAN_REF" ]] || die "--plan is required"
|
|
28
|
+
|
|
29
|
+
if [[ -f "$PLAN_REF" ]]; then
|
|
30
|
+
PLAN_PATH="$PLAN_REF"
|
|
31
|
+
else
|
|
32
|
+
PLAN_PATH="$PLAN_DIR/${PLAN_REF}.plan.json"
|
|
33
|
+
[[ -f "$PLAN_PATH" ]] || PLAN_PATH="$PLAN_DIR/${PLAN_REF}"
|
|
34
|
+
fi
|
|
35
|
+
[[ -f "$PLAN_PATH" ]] || die "plan not found: $PLAN_REF"
|
|
36
|
+
require_cmd node
|
|
37
|
+
|
|
38
|
+
PLAN_ID="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.planId)' "$PLAN_PATH")"
|
|
39
|
+
[[ "$PLAN_ID" =~ ^upg_[0-9TZ]+_[a-f0-9]+$ ]] || die "plan id is unsafe"
|
|
40
|
+
CURRENT="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.currentVersion)' "$PLAN_PATH")"
|
|
41
|
+
TARGET="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.targetVersion)' "$PLAN_PATH")"
|
|
42
|
+
assert_safe_version "$CURRENT" "current version"
|
|
43
|
+
assert_safe_version "$TARGET" "target version"
|
|
44
|
+
STATE_PATH="$PLAN_DIR/${PLAN_ID}.state.json"
|
|
45
|
+
[[ -f "$STATE_PATH" ]] || die "state sidecar missing for plan $PLAN_ID"
|
|
46
|
+
STATE_PLAN_ID="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.planId||"")' "$STATE_PATH")"
|
|
47
|
+
[[ "$STATE_PLAN_ID" == "$PLAN_ID" ]] || die "state sidecar plan id mismatch"
|
|
48
|
+
|
|
49
|
+
EXPECTED_PLAN_SHA="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.planSha256||"")' "$STATE_PATH")"
|
|
50
|
+
[[ -n "$EXPECTED_PLAN_SHA" ]] || die "state sidecar missing immutable plan checksum"
|
|
51
|
+
ACTUAL_PLAN_SHA="$(sha256_file "$PLAN_PATH")"
|
|
52
|
+
[[ "$ACTUAL_PLAN_SHA" == "$EXPECTED_PLAN_SHA" ]] || die "immutable plan checksum mismatch (re-plan required)"
|
|
53
|
+
|
|
54
|
+
errors=0
|
|
55
|
+
fail() { printf 'preflight fail: %s\n' "$*" >&2; errors=$((errors + 1)); }
|
|
56
|
+
ok() { printf 'preflight ok: %s\n' "$*"; }
|
|
57
|
+
|
|
58
|
+
# Plan immutability: refuse if plan body changed vs fingerprint of required fields
|
|
59
|
+
node -e '
|
|
60
|
+
const j=require(process.argv[1]);
|
|
61
|
+
if(!j.immutable) process.exit(2);
|
|
62
|
+
if(!j.planId || !j.currentVersion || !j.targetVersion) process.exit(3);
|
|
63
|
+
if(j.currentVersion === j.targetVersion) {
|
|
64
|
+
console.error("target equals current — nothing to upgrade (fail closed for empty apply)");
|
|
65
|
+
process.exit(4);
|
|
66
|
+
}
|
|
67
|
+
' "$PLAN_PATH" || fail "plan invalid or target equals current"
|
|
68
|
+
|
|
69
|
+
# Database reachable when plan recorded a source database
|
|
70
|
+
SOURCE_DB="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.sourceDatabase||"")' "$PLAN_PATH")"
|
|
71
|
+
if [[ -n "$SOURCE_DB" ]]; then
|
|
72
|
+
require_pg_client
|
|
73
|
+
URL="$(resolve_database_url)"
|
|
74
|
+
require_database_url "$URL"
|
|
75
|
+
ACTIVE="$(db_name_from_url "$URL")"
|
|
76
|
+
if [[ "$ACTIVE" != "$SOURCE_DB" ]]; then
|
|
77
|
+
fail "active database '$ACTIVE' does not match plan sourceDatabase '$SOURCE_DB'"
|
|
78
|
+
else
|
|
79
|
+
ok "active database matches plan ($ACTIVE)"
|
|
80
|
+
fi
|
|
81
|
+
if ! psql_q "$URL" -c 'SELECT 1' >/dev/null 2>&1; then
|
|
82
|
+
fail "cannot connect to DATABASE_URL"
|
|
83
|
+
else
|
|
84
|
+
ok "database connectivity"
|
|
85
|
+
fi
|
|
86
|
+
if require_application_release "$URL" "$CURRENT"; then
|
|
87
|
+
ok "application release contract matches plan ($CURRENT)"
|
|
88
|
+
else
|
|
89
|
+
fail "application release contract does not match plan currentVersion"
|
|
90
|
+
fi
|
|
91
|
+
if bash "$ROOT/scripts/scim-legacy-preflight.sh" >/dev/null; then
|
|
92
|
+
ok "legacy personal/global SCIM credential inventory is empty"
|
|
93
|
+
else
|
|
94
|
+
fail "legacy personal/global SCIM credentials remain; inventory and revoke before upgrade"
|
|
95
|
+
fi
|
|
96
|
+
# Schema fingerprint drift warning as hard fail for preflight
|
|
97
|
+
EV="$(mktemp "${TMPDIR:-/tmp}/clearance-pre-ev.XXXXXX")"
|
|
98
|
+
collect_db_evidence "$URL" "$EV"
|
|
99
|
+
NOW_FP="$(schema_fingerprint_from_evidence "$EV")"
|
|
100
|
+
rm -f "$EV"
|
|
101
|
+
PLAN_FP="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.schemaFingerprintSha256||"")' "$PLAN_PATH")"
|
|
102
|
+
if [[ -n "$PLAN_FP" && "$PLAN_FP" != "no-database-url" && "$PLAN_FP" != "$NOW_FP" ]]; then
|
|
103
|
+
fail "schema fingerprint changed since plan was created (re-plan required)"
|
|
104
|
+
else
|
|
105
|
+
ok "schema fingerprint matches plan"
|
|
106
|
+
fi
|
|
107
|
+
else
|
|
108
|
+
ok "plan has no source database (offline plan)"
|
|
109
|
+
fi
|
|
110
|
+
|
|
111
|
+
# Production env optional: if CLEARANCE_STRICT_SECRETS=1, require strong secrets
|
|
112
|
+
if [[ "${CLEARANCE_STRICT_SECRETS:-}" == "1" ]]; then
|
|
113
|
+
if ! bash "$ROOT/scripts/validate-production-env.sh" >/dev/null 2>&1; then
|
|
114
|
+
fail "CLEARANCE_STRICT_SECRETS=1 but validate-production-env failed"
|
|
115
|
+
else
|
|
116
|
+
ok "production env validation"
|
|
117
|
+
fi
|
|
118
|
+
fi
|
|
119
|
+
|
|
120
|
+
if [[ "$errors" -ne 0 ]]; then
|
|
121
|
+
die "upgrade-preflight failed ($errors checks)"
|
|
122
|
+
fi
|
|
123
|
+
|
|
124
|
+
node -e '
|
|
125
|
+
const fs=require("fs");
|
|
126
|
+
const p=process.argv[1];
|
|
127
|
+
const j=JSON.parse(fs.readFileSync(p,"utf8"));
|
|
128
|
+
j.status="preflight_ok";
|
|
129
|
+
j.updatedAt=new Date().toISOString().replace(/\.\d{3}Z$/,"Z");
|
|
130
|
+
j.applyJournal=j.applyJournal||[];
|
|
131
|
+
j.applyJournal.push({at:j.updatedAt, event:"preflight_ok"});
|
|
132
|
+
fs.writeFileSync(p, JSON.stringify(j,null,2)+"\n");
|
|
133
|
+
' "$STATE_PATH"
|
|
134
|
+
|
|
135
|
+
printf 'upgrade-preflight: OK plan=%s\n' "$PLAN_ID"
|
|
136
|
+
printf 'PREFLIGHT_OK=1\n'
|
|
@@ -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'
|