@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.
@@ -0,0 +1,375 @@
1
+ # Shared helpers for Clearance operational scripts.
2
+ # shellcheck shell=bash
3
+ # Sourced by backup/upgrade/validate scripts — not executed directly.
4
+ #
5
+ # Callers must run under: set -Eeuo pipefail
6
+
7
+ if [[ -n "${OPS_COMMON_LOADED:-}" ]]; then
8
+ return 0 2>/dev/null || exit 0
9
+ fi
10
+ OPS_COMMON_LOADED=1
11
+
12
+ die() {
13
+ printf 'error: %s\n' "$*" >&2
14
+ exit 1
15
+ }
16
+
17
+ require_cmd() {
18
+ command -v "$1" >/dev/null 2>&1 || die "required command not found: $1"
19
+ }
20
+
21
+ # Never print secret values. Label-only diagnostics.
22
+ redact_url() {
23
+ local url="${1:-}"
24
+ if [[ -z "$url" ]]; then
25
+ printf '%s' "(empty)"
26
+ return
27
+ fi
28
+ # Strip userinfo credentials if present.
29
+ printf '%s' "$url" | sed -E \
30
+ -e 's#(postgres(ql)?://)[^/@]+@#\1***@#' \
31
+ -e 's#([?&](password|sslpassword|token|secret)=)[^&]*#\1***#g'
32
+ }
33
+
34
+ # Known-insecure defaults (mirrors packages/management secret policy).
35
+ is_forbidden_secret() {
36
+ local secret="${1:-}"
37
+ local lower
38
+ if [[ -z "$secret" ]]; then
39
+ return 0
40
+ fi
41
+ if (( ${#secret} < 16 )); then
42
+ return 0
43
+ fi
44
+ lower="$(printf '%s' "$secret" | tr '[:upper:]' '[:lower:]')"
45
+ case "$lower" in
46
+ "dev-secret-change-me"|"dev-secret-change-me-please-32chars!!"|"secret"|"local-compose-secret-change-me-32"|"test-secret-value-32-characters"|"test-secret-value-that-is-long-enough"|"test-secret-value-that-is-long-enough-32"|"change-me"|"password"|"clearance"|"clearance-secret"|"postgres"|"admin"|"changeme")
47
+ return 0
48
+ ;;
49
+ esac
50
+ if [[ "$lower" == *"change-me"* || "$lower" == *"dev-secret"* || "$lower" == *"replace-me"* ]]; then
51
+ return 0
52
+ fi
53
+ return 1
54
+ }
55
+
56
+ require_strong_secret() {
57
+ local label="$1"
58
+ local value="${2:-}"
59
+ if is_forbidden_secret "$value"; then
60
+ die "$label is missing, empty, shorter than 16 chars, or a known weak default"
61
+ fi
62
+ }
63
+
64
+ # Reject obvious non-production placeholders used as DATABASE_URL.
65
+ is_weak_database_url() {
66
+ local url="${1:-}"
67
+ local lower
68
+ if [[ -z "$url" ]]; then
69
+ return 0
70
+ fi
71
+ lower="$(printf '%s' "$url" | tr '[:upper:]' '[:lower:]')"
72
+ case "$lower" in
73
+ *"://clearance:clearance@"*|*"://postgres:postgres@"*|*"://user:pass@"*|*"://user:password@"*|*changeme*|*change-me*|*example.com*)
74
+ return 0
75
+ ;;
76
+ esac
77
+ # Missing scheme or host
78
+ if [[ ! "$url" =~ ^postgres(ql)?://[^[:space:]]+$ ]]; then
79
+ return 0
80
+ fi
81
+ return 1
82
+ }
83
+
84
+ require_database_url() {
85
+ local url="${1:-}"
86
+ if is_weak_database_url "$url"; then
87
+ die "DATABASE_URL is missing, not a postgres URL, or uses a weak/default credential (redacted=$(redact_url "$url"))"
88
+ fi
89
+ }
90
+
91
+ sha256_file() {
92
+ local path="$1"
93
+ if command -v sha256sum >/dev/null 2>&1; then
94
+ sha256sum "$path" | awk '{print $1}'
95
+ else
96
+ shasum -a 256 "$path" | awk '{print $1}'
97
+ fi
98
+ }
99
+
100
+ iso_now() {
101
+ date -u +"%Y-%m-%dT%H:%M:%SZ"
102
+ }
103
+
104
+ # Resolve DATABASE_URL for host-side pg tools. Prefer explicit DATABASE_URL.
105
+ resolve_database_url() {
106
+ if [[ -n "${DATABASE_URL:-}" ]]; then
107
+ printf '%s' "$DATABASE_URL"
108
+ return
109
+ fi
110
+ die "DATABASE_URL is required (postgres connection string for the active environment)"
111
+ }
112
+
113
+ # Parse active database name from DATABASE_URL without printing secrets.
114
+ db_name_from_url() {
115
+ local url="$1"
116
+ local path
117
+ path="$(printf '%s' "$url" | sed -E 's#^[a-zA-Z0-9+.-]+://[^/]+/##' | sed 's#[?#].*##')"
118
+ # URL-decode minimal %xx if present
119
+ if [[ -z "$path" || "$path" == *"/"* ]]; then
120
+ die "could not parse database name from DATABASE_URL"
121
+ fi
122
+ printf '%s' "$path"
123
+ }
124
+
125
+ # Admin URL that connects to maintenance db (postgres) for CREATE/DROP DATABASE.
126
+ admin_url_from_url() {
127
+ local url="$1"
128
+ # Replace path with /postgres while preserving query string.
129
+ if [[ "$url" == *"?"* ]]; then
130
+ printf '%s' "$url" | sed -E 's#(postgres(ql)?://[^/]+)/[^?]+#\1/postgres#'
131
+ else
132
+ printf '%s' "$url" | sed -E 's#(postgres(ql)?://[^/]+)/.*#\1/postgres#'
133
+ fi
134
+ }
135
+
136
+ # Build a URL pointing at a different database name (same host/user).
137
+ url_with_db() {
138
+ local url="$1"
139
+ local db="$2"
140
+ if [[ "$url" == *"?"* ]]; then
141
+ printf '%s' "$url" | sed -E "s#(postgres(ql)?://[^/]+)/[^?]+#\1/${db}#"
142
+ else
143
+ printf '%s' "$url" | sed -E "s#(postgres(ql)?://[^/]+)/.*#\1/${db}#"
144
+ fi
145
+ }
146
+
147
+ json_escape() {
148
+ # Minimal JSON string escape for plan/meta files.
149
+ if command -v python3 >/dev/null 2>&1; then
150
+ printf '%s' "$1" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()), end="")'
151
+ else
152
+ JSON_ESCAPE_VALUE="$1" node -e 'process.stdout.write(JSON.stringify(process.env.JSON_ESCAPE_VALUE))'
153
+ fi
154
+ }
155
+
156
+ # Active DB protection: refuse operations that target the live database name for drop/overwrite.
157
+ assert_not_active_db() {
158
+ local candidate="$1"
159
+ local active
160
+ active="$(db_name_from_url "$(resolve_database_url)")"
161
+ if [[ "$candidate" == "$active" ]]; then
162
+ die "refusing operation on active database '$candidate' (isolated names only)"
163
+ fi
164
+ case "$candidate" in
165
+ postgres|template0|template1)
166
+ die "refusing operation on system database '$candidate'"
167
+ ;;
168
+ esac
169
+ }
170
+
171
+ # Safe identifier: lowercase alphanumeric + underscore only.
172
+ assert_safe_ident() {
173
+ local name="$1"
174
+ local label="${2:-identifier}"
175
+ if [[ ! "$name" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then
176
+ die "$label must be a simple SQL identifier (got unsafe value)"
177
+ fi
178
+ if (( ${#name} > 63 )); then
179
+ die "$label exceeds 63 characters"
180
+ fi
181
+ }
182
+
183
+ # Release values are passed to SQL hooks and filesystem paths. Keep the
184
+ # accepted grammar deliberately narrower than arbitrary operator input.
185
+ assert_safe_version() {
186
+ local version="$1"
187
+ local label="${2:-version}"
188
+ if [[ ! "$version" =~ ^[0-9A-Za-z][0-9A-Za-z.+_-]{0,63}$ ]]; then
189
+ die "$label is not a safe release version"
190
+ fi
191
+ }
192
+
193
+ psql_q() {
194
+ # Quiet psql: never echo connection string beyond env; use argv.
195
+ local url="$1"
196
+ shift
197
+ PGPASSWORD="${PGPASSWORD:-}" psql --no-psqlrc -v ON_ERROR_STOP=1 "$url" "$@"
198
+ }
199
+
200
+ # Collect lightweight resource evidence from a database (counts, table list).
201
+ # Schema fingerprint consumers should hash only the stable "tables" object
202
+ # (see schema_fingerprint_from_evidence); capturedAt is observational only.
203
+ collect_db_evidence() {
204
+ local url="$1"
205
+ local out_file="$2"
206
+
207
+ {
208
+ printf '{\n'
209
+ printf ' "capturedAt": %s,\n' "$(json_escape "$(iso_now)")"
210
+ printf ' "tables": {'
211
+ local first=1
212
+ # Prefer known management/runtime tables when present; otherwise all public base tables.
213
+ local tables
214
+ tables="$(psql_q "$url" -At -c "
215
+ SELECT table_name FROM information_schema.tables
216
+ WHERE table_schema='public' AND table_type='BASE TABLE'
217
+ ORDER BY table_name;
218
+ " 2>/dev/null || true)"
219
+ if [[ -n "$tables" ]]; then
220
+ while IFS= read -r t; do
221
+ [[ -z "$t" ]] && continue
222
+ assert_safe_ident "$t" "table"
223
+ local c
224
+ c="$(psql_q "$url" -At -c "SELECT count(*)::text FROM \"${t}\";" 2>/dev/null || echo "null")"
225
+ if [[ $first -eq 1 ]]; then
226
+ first=0
227
+ else
228
+ printf ','
229
+ fi
230
+ printf '\n %s: %s' "$(json_escape "$t")" "$c"
231
+ done <<<"$tables"
232
+ fi
233
+ printf '\n }\n'
234
+ printf '}\n'
235
+ } >"$out_file"
236
+ }
237
+
238
+ # Stable sha256 over table->count map only (ignores capturedAt).
239
+ schema_fingerprint_from_evidence() {
240
+ local evidence_file="$1"
241
+ node -e '
242
+ const fs=require("fs");
243
+ const crypto=require("crypto");
244
+ const j=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));
245
+ const tables=j.tables||{};
246
+ const keys=Object.keys(tables).sort();
247
+ const stable={};
248
+ for (const k of keys) stable[k]=tables[k];
249
+ process.stdout.write(crypto.createHash("sha256").update(JSON.stringify(stable)).digest("hex"));
250
+ ' "$evidence_file"
251
+ }
252
+
253
+ # The application release contract lives in the durable management snapshot,
254
+ # which is created and consumed by the running Clearance API.
255
+ application_release_version() {
256
+ local url="$1"
257
+ { psql_q "$url" -At -c "
258
+ SELECT data->>'releaseVersion'
259
+ FROM clearance_management_snapshot
260
+ WHERE id = 1;
261
+ " 2>/dev/null || true; } | head -n1
262
+ }
263
+
264
+ require_application_release() {
265
+ local url="$1"
266
+ local expected="$2"
267
+ assert_safe_version "$expected" "expected application release"
268
+ local actual
269
+ actual="$(application_release_version "$url")"
270
+ [[ -n "$actual" ]] || die "active database has no Clearance application release contract"
271
+ [[ "$actual" == "$expected" ]] \
272
+ || die "application release mismatch: expected $expected, found $actual"
273
+ }
274
+
275
+ # Compare the complete public-table count map captured in backup metadata with
276
+ # evidence collected after a restore. New, missing, or changed tables all fail.
277
+ compare_backup_evidence() {
278
+ local meta_file="$1"
279
+ local restored_evidence_file="$2"
280
+ node -e '
281
+ const fs=require("fs");
282
+ const meta=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));
283
+ const restored=JSON.parse(fs.readFileSync(process.argv[2],"utf8"));
284
+ const expected=meta.resourceCounts || {};
285
+ const actual=restored.tables || {};
286
+ const keys=Object.keys(expected).sort();
287
+ const actualKeys=Object.keys(actual).sort();
288
+ let mismatches=0;
289
+ for (const k of keys) {
290
+ const e=expected[k];
291
+ const a=actual[k];
292
+ if (typeof e !== "number" || a === undefined || a === null || Number(a) !== Number(e)) {
293
+ console.error(`count mismatch for ${k}: expected ${e}, restored ${a}`);
294
+ mismatches++;
295
+ }
296
+ }
297
+ if (JSON.stringify(keys) !== JSON.stringify(actualKeys)) {
298
+ console.error(`table set mismatch: expected ${keys.join(",")}; restored ${actualKeys.join(",")}`);
299
+ mismatches++;
300
+ }
301
+ if (mismatches > 0) process.exit(1);
302
+ process.stdout.write(JSON.stringify({ok:true, comparedTables:keys.length, restoredTables:actualKeys.length, sourceDatabase:meta.sourceDatabase}));
303
+ ' "$meta_file" "$restored_evidence_file"
304
+ }
305
+
306
+ require_pg_client() {
307
+ require_cmd psql
308
+ require_cmd pg_dump
309
+ }
310
+
311
+ # Map DATABASE_URL host for tools running inside Docker (Desktop/Mac/Windows).
312
+ dockerized_database_url() {
313
+ local url="$1"
314
+ printf '%s' "$url" \
315
+ | sed -E 's#(@|://)127\.0\.0\.1#\1host.docker.internal#g' \
316
+ | sed -E 's#(@|://)localhost#\1host.docker.internal#g'
317
+ }
318
+
319
+ # Detect server major version (e.g. 16) without printing secrets.
320
+ pg_server_major() {
321
+ local url="$1"
322
+ local full
323
+ full="$(psql_q "$url" -At -c 'SHOW server_version;' 2>/dev/null | head -n1 | awk '{print $1}')"
324
+ if [[ -z "$full" ]]; then
325
+ die "could not determine postgres server version"
326
+ fi
327
+ printf '%s' "${full%%.*}"
328
+ }
329
+
330
+ # Real pg_dump to file. Prefer local client; on version mismatch use matching postgres image.
331
+ # Never prints the connection string.
332
+ pg_dump_to_file() {
333
+ local url="$1"
334
+ local out="$2"
335
+ local snapshot="${3:-}"
336
+ local -a snapshot_arg=()
337
+ if [[ -n "$snapshot" ]]; then
338
+ snapshot_arg=("--snapshot=$snapshot")
339
+ fi
340
+ local err
341
+ err="$(mktemp "${TMPDIR:-/tmp}/clearance-pgdump-err.XXXXXX")"
342
+ if pg_dump --no-owner --no-acl --format=plain "${snapshot_arg[@]}" --dbname="$url" --file="$out" 2>"$err"; then
343
+ find "$err" -type f -delete 2>/dev/null || true
344
+ return 0
345
+ fi
346
+ if ! grep -qi 'version mismatch' "$err"; then
347
+ # surface non-secret error text only
348
+ printf 'pg_dump failed: %s\n' "$(redact_url "$(tr '\n' ' ' <"$err")")" >&2
349
+ find "$err" -type f -delete 2>/dev/null || true
350
+ die "pg_dump failed"
351
+ fi
352
+ find "$err" -type f -delete 2>/dev/null || true
353
+ require_cmd docker
354
+ local major docker_url img
355
+ major="$(pg_server_major "$url")"
356
+ docker_url="$(dockerized_database_url "$url")"
357
+ img="postgres:${major}-alpine"
358
+ printf 'backup: local pg_dump version mismatch; using %s\n' "$img" >&2
359
+ # host.docker.internal works on Docker Desktop; host-gateway helps Linux.
360
+ if ! docker run --rm \
361
+ --add-host=host.docker.internal:host-gateway \
362
+ "$img" \
363
+ pg_dump --no-owner --no-acl --format=plain "${snapshot_arg[@]}" --dbname="$docker_url" >"$out"
364
+ then
365
+ die "dockerized pg_dump failed (image=$img)"
366
+ fi
367
+ [[ -s "$out" ]] || die "dockerized pg_dump produced empty file"
368
+ }
369
+
370
+ # Restore plain SQL dump into target URL (local psql is generally forward-compatible).
371
+ psql_restore_file() {
372
+ local url="$1"
373
+ local dump="$2"
374
+ psql_q "$url" -f "$dump" >/dev/null
375
+ }
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env bash
2
+ # Inventory and optionally revoke legacy personal/global SCIM credentials.
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: scim-legacy-preflight.sh [--revoke --confirm TOKEN] [--receipt-dir DIR]
13
+
14
+ Rows in the runtime scimProvider table with no organizationId were minted by
15
+ the legacy personal/global path. Inventory is the default and fails closed when
16
+ any remain. To revoke them, rerun with the exact token printed by inventory:
17
+ REVOKE_LEGACY_SCIM:<database>:<count>
18
+
19
+ No bearer token or encrypted credential is read or printed.
20
+ EOF
21
+ }
22
+
23
+ REVOKE=0
24
+ CONFIRM=""
25
+ RECEIPT_DIR="${CLEARANCE_SECURITY_RECEIPT_DIR:-$ROOT/.clearance/security-receipts}"
26
+ while [[ $# -gt 0 ]]; do
27
+ case "$1" in
28
+ --revoke) REVOKE=1; shift ;;
29
+ --confirm) CONFIRM="$2"; shift 2 ;;
30
+ --receipt-dir) RECEIPT_DIR="$2"; shift 2 ;;
31
+ -h|--help) usage; exit 0 ;;
32
+ *) die "unknown argument: $1" ;;
33
+ esac
34
+ done
35
+
36
+ require_pg_client
37
+ URL="$(resolve_database_url)"
38
+ require_database_url "$URL"
39
+ DB="$(db_name_from_url "$URL")"
40
+ assert_safe_ident "$DB" "active database"
41
+ HAS_TABLE="$(psql_q "$URL" -At -c "SELECT CASE WHEN to_regclass('public.\"scimProvider\"') IS NULL THEN 0 ELSE 1 END")"
42
+ if [[ "$HAS_TABLE" == "0" ]]; then
43
+ printf 'scim-legacy-preflight: OK; runtime SCIM table is absent (zero issued credentials)\n'
44
+ printf 'LEGACY_SCIM_COUNT=0\nSCIM_LEGACY_PREFLIGHT_OK=1\n'
45
+ exit 0
46
+ fi
47
+
48
+ COUNT="$(psql_q "$URL" -At -c "SELECT count(*) FROM \"scimProvider\" WHERE \"organizationId\" IS NULL OR btrim(\"organizationId\") = ''")"
49
+ [[ "$COUNT" =~ ^[0-9]+$ ]] || die "could not inventory legacy SCIM credentials"
50
+ if [[ "$COUNT" == "0" ]]; then
51
+ printf 'scim-legacy-preflight: OK; no personal/global SCIM credentials remain\n'
52
+ printf 'LEGACY_SCIM_COUNT=0\nSCIM_LEGACY_PREFLIGHT_OK=1\n'
53
+ exit 0
54
+ fi
55
+
56
+ TOKEN="REVOKE_LEGACY_SCIM:${DB}:${COUNT}"
57
+ if [[ "$REVOKE" -eq 0 ]]; then
58
+ printf 'scim-legacy-preflight: BLOCKED; %s legacy personal/global credential(s) remain\n' "$COUNT" >&2
59
+ printf 'rerun with: --revoke --confirm %s\n' "$TOKEN" >&2
60
+ exit 1
61
+ fi
62
+ [[ "$CONFIRM" == "$TOKEN" ]] || die "revoke confirmation mismatch; required --confirm $TOKEN"
63
+
64
+ if [[ "${CLEARANCE_OPS_TESTING:-0}" == "1" && -n "${CLEARANCE_SCIM_PREFLIGHT_TEST_DELAY_SECONDS:-}" ]]; then
65
+ sleep "$CLEARANCE_SCIM_PREFLIGHT_TEST_DELAY_SECONDS"
66
+ fi
67
+
68
+ DELETED="$(psql_q "$URL" -At -v expected="$COUNT" <<'SQL'
69
+ BEGIN;
70
+ SELECT pg_advisory_xact_lock(7176324950072027);
71
+ LOCK TABLE "scimProvider" IN ACCESS EXCLUSIVE MODE;
72
+ WITH deleted AS (
73
+ DELETE FROM "scimProvider"
74
+ WHERE ("organizationId" IS NULL OR btrim("organizationId") = '')
75
+ AND (SELECT count(*) FROM "scimProvider"
76
+ WHERE "organizationId" IS NULL OR btrim("organizationId") = '') = :'expected'::bigint
77
+ RETURNING id
78
+ )
79
+ SELECT count(*) FROM deleted;
80
+ COMMIT;
81
+ SQL
82
+ )"
83
+ DELETED="$(printf '%s\n' "$DELETED" | awk '/^[0-9]+$/ {v=$0} END {print v}')"
84
+ [[ "$DELETED" == "$COUNT" ]] || die "legacy SCIM revoke count mismatch"
85
+ REMAINING="$(psql_q "$URL" -At -c "SELECT count(*) FROM \"scimProvider\" WHERE \"organizationId\" IS NULL OR btrim(\"organizationId\") = ''")"
86
+ [[ "$REMAINING" == "0" ]] || die "legacy SCIM credentials remain after revoke"
87
+
88
+ mkdir -p "$RECEIPT_DIR"
89
+ chmod 700 "$RECEIPT_DIR"
90
+ RECEIPT="$RECEIPT_DIR/scim-legacy-revoke-$(date -u +%Y%m%dT%H%M%SZ).json"
91
+ cat >"$RECEIPT" <<EOF
92
+ {
93
+ "database": $(json_escape "$DB"),
94
+ "revokedAt": $(json_escape "$(iso_now)"),
95
+ "legacyCredentialsRevoked": $DELETED,
96
+ "remainingLegacyCredentials": 0,
97
+ "tokenMaterialInspected": false
98
+ }
99
+ EOF
100
+ chmod 400 "$RECEIPT"
101
+ printf 'scim-legacy-preflight: OK; revoked %s personal/global credentials\n' "$DELETED"
102
+ printf 'SCIM_REVOKE_RECEIPT=%s\n' "$RECEIPT"
103
+ printf 'SCIM_LEGACY_PREFLIGHT_OK=1\n'
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env bash
2
+ # Apply an upgrade plan: preflight + verified backup, then controlled apply steps.
3
+ # Fail closed. Records rollback reference. Does not silently mutate without backup.
4
+ set -Eeuo pipefail
5
+
6
+ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
7
+ # shellcheck source=lib/ops-common.sh
8
+ source "$ROOT/scripts/lib/ops-common.sh"
9
+
10
+ usage() {
11
+ cat <<'EOF'
12
+ Usage: upgrade-apply.sh --plan PLAN_ID_OR_PATH [--dir DIR] [--backup-dir DIR]
13
+
14
+ Sequence (fail closed):
15
+ 1. preflight
16
+ 2. backup-create + backup-verify (mandatory)
17
+ 3. required deploy/upgrades/steps/<target>/apply.sh
18
+ 4. write version marker file + state journal
19
+ 5. record rollbackReference = verified backup
20
+
21
+ Does not drop or overwrite the active database.
22
+ EOF
23
+ }
24
+
25
+ PLAN_DIR="${CLEARANCE_UPGRADE_DIR:-$ROOT/.clearance/upgrades}"
26
+ BACKUP_DIR="${CLEARANCE_BACKUP_DIR:-$ROOT/.clearance/backups}"
27
+ PLAN_REF=""
28
+
29
+ while [[ $# -gt 0 ]]; do
30
+ case "$1" in
31
+ --plan) PLAN_REF="$2"; shift 2 ;;
32
+ --dir) PLAN_DIR="$2"; shift 2 ;;
33
+ --backup-dir) BACKUP_DIR="$2"; shift 2 ;;
34
+ -h|--help) usage; exit 0 ;;
35
+ *) die "unknown argument: $1" ;;
36
+ esac
37
+ done
38
+
39
+ [[ -n "$PLAN_REF" ]] || die "--plan is required"
40
+
41
+ if [[ -f "$PLAN_REF" ]]; then
42
+ PLAN_PATH="$PLAN_REF"
43
+ else
44
+ PLAN_PATH="$PLAN_DIR/${PLAN_REF}.plan.json"
45
+ fi
46
+ [[ -f "$PLAN_PATH" ]] || die "plan not found"
47
+ require_cmd node
48
+
49
+ PLAN_ID="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.planId)' "$PLAN_PATH")"
50
+ [[ "$PLAN_ID" =~ ^upg_[0-9TZ]+_[a-f0-9]+$ ]] || die "plan id is unsafe"
51
+ STATE_PATH="$PLAN_DIR/${PLAN_ID}.state.json"
52
+ TARGET="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.targetVersion)' "$PLAN_PATH")"
53
+ CURRENT="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.currentVersion)' "$PLAN_PATH")"
54
+ assert_safe_version "$CURRENT" "current version"
55
+ assert_safe_version "$TARGET" "target version"
56
+
57
+ # 1. Preflight
58
+ bash "$ROOT/scripts/upgrade-preflight.sh" --plan "$PLAN_PATH" --dir "$PLAN_DIR"
59
+
60
+ # 2. Mandatory verified backup when DATABASE_URL is set (or plan has source DB)
61
+ SOURCE_DB="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.sourceDatabase||"")' "$PLAN_PATH")"
62
+ BACKUP_ID=""
63
+ BACKUP_DUMP=""
64
+ BACKUP_META=""
65
+ BACKUP_EVIDENCE=""
66
+
67
+ if [[ -n "$SOURCE_DB" || -n "${DATABASE_URL:-}" ]]; then
68
+ require_pg_client
69
+ URL="$(resolve_database_url)"
70
+ export DATABASE_URL="$URL"
71
+ CREATE_OUT="$(bash "$ROOT/scripts/backup-create.sh" --dir "$BACKUP_DIR")"
72
+ BACKUP_ID="$(printf '%s\n' "$CREATE_OUT" | sed -n 's/^BACKUP_ID=//p' | tail -1)"
73
+ BACKUP_DUMP="$(printf '%s\n' "$CREATE_OUT" | sed -n 's/^BACKUP_DUMP=//p' | tail -1)"
74
+ BACKUP_META="$(printf '%s\n' "$CREATE_OUT" | sed -n 's/^BACKUP_META=//p' | tail -1)"
75
+ BACKUP_EVIDENCE="$(printf '%s\n' "$CREATE_OUT" | sed -n 's/^BACKUP_EVIDENCE=//p' | tail -1)"
76
+ [[ -n "$BACKUP_ID" && -f "$BACKUP_DUMP" && -f "$BACKUP_META" && -f "$BACKUP_EVIDENCE" ]] || die "backup-create failed to produce artifacts"
77
+ bash "$ROOT/scripts/backup-verify.sh" --id "$BACKUP_ID" --dir "$BACKUP_DIR" >/dev/null
78
+ # Isolated restore verification before apply
79
+ bash "$ROOT/scripts/backup-restore-verify.sh" --id "$BACKUP_ID" --dir "$BACKUP_DIR" >/dev/null
80
+ else
81
+ die "upgrade-apply requires DATABASE_URL / plan sourceDatabase so a verified backup can be taken"
82
+ fi
83
+
84
+ # Persist the verified rollback reference before any version-specific hook runs.
85
+ # A missing or failing hook therefore leaves recoverable evidence in plan state.
86
+ node -e '
87
+ const fs=require("fs");
88
+ const p=process.argv[1];
89
+ const backupId=process.argv[2];
90
+ const dump=process.argv[3];
91
+ const meta=process.argv[4];
92
+ const evidence=process.argv[5];
93
+ const j=JSON.parse(fs.readFileSync(p,"utf8"));
94
+ const at=new Date().toISOString().replace(/\.\d{3}Z$/,"Z");
95
+ j.status="backup_verified";
96
+ j.updatedAt=at;
97
+ j.backupId=backupId;
98
+ j.backupDump=dump;
99
+ j.backupMeta=meta;
100
+ j.backupEvidence=evidence;
101
+ j.rollbackReference={
102
+ type:"verified_pg_dump",
103
+ backupId,
104
+ dump,
105
+ meta,
106
+ evidence,
107
+ note:"Verified by checksum, archive inspection, and isolated restore before apply"
108
+ };
109
+ j.applyJournal=j.applyJournal||[];
110
+ j.applyJournal.push({at,event:"backup_verified",backupId});
111
+ fs.writeFileSync(p,JSON.stringify(j,null,2)+"\n");
112
+ ' "$STATE_PATH" "$BACKUP_ID" "$BACKUP_DUMP" "$BACKUP_META" "$BACKUP_EVIDENCE"
113
+
114
+ # 3. Required version-specific apply hook (operator-supplied, fail closed if absent/non-zero)
115
+ STEP="$ROOT/deploy/upgrades/steps/${TARGET}/apply.sh"
116
+ if [[ -f "$STEP" ]]; then
117
+ printf 'upgrade-apply: running step hook %s\n' "$STEP" >&2
118
+ bash "$STEP" --plan "$PLAN_PATH" --from "$CURRENT" --to "$TARGET" \
119
+ || die "apply step hook failed (fail closed); rollbackReference=$BACKUP_ID"
120
+ else
121
+ die "upgrade step hook missing: $STEP (refusing to record a no-op version transition); verified rollbackReference=$BACKUP_ID"
122
+ fi
123
+
124
+ # 4. Version marker (operational reference; not a fake health claim)
125
+ MARKER_DIR="$PLAN_DIR/markers"
126
+ mkdir -p "$MARKER_DIR"
127
+ MARKER="$MARKER_DIR/${PLAN_ID}.applied"
128
+ cat >"$MARKER" <<EOF
129
+ {
130
+ "planId": $(json_escape "$PLAN_ID"),
131
+ "fromVersion": $(json_escape "$CURRENT"),
132
+ "toVersion": $(json_escape "$TARGET"),
133
+ "appliedAt": $(json_escape "$(iso_now)"),
134
+ "backupId": $(json_escape "$BACKUP_ID"),
135
+ "backupDump": $(json_escape "$BACKUP_DUMP"),
136
+ "backupMeta": $(json_escape "$BACKUP_META")
137
+ ,"backupEvidence": $(json_escape "$BACKUP_EVIDENCE")
138
+ }
139
+ EOF
140
+
141
+ # 5. State update with rollback reference
142
+ node -e '
143
+ const fs=require("fs");
144
+ const p=process.argv[1];
145
+ const backupId=process.argv[2];
146
+ const dump=process.argv[3];
147
+ const meta=process.argv[4];
148
+ const marker=process.argv[5];
149
+ const evidence=process.argv[6];
150
+ const j=JSON.parse(fs.readFileSync(p,"utf8"));
151
+ const at=new Date().toISOString().replace(/\.\d{3}Z$/,"Z");
152
+ j.status="applied";
153
+ j.updatedAt=at;
154
+ j.backupId=backupId;
155
+ j.backupDump=dump;
156
+ j.backupMeta=meta;
157
+ j.backupEvidence=evidence;
158
+ j.rollbackReference.note="Apply completed; active restore remains an explicit operator runbook action";
159
+ j.applyJournal=j.applyJournal||[];
160
+ j.applyJournal.push({at, event:"applied", marker, backupId});
161
+ fs.writeFileSync(p, JSON.stringify(j,null,2)+"\n");
162
+ ' "$STATE_PATH" "$BACKUP_ID" "$BACKUP_DUMP" "$BACKUP_META" "$MARKER" "$BACKUP_EVIDENCE"
163
+
164
+ printf 'upgrade-apply: OK plan=%s target=%s backup=%s\n' "$PLAN_ID" "$TARGET" "$BACKUP_ID" >&2
165
+ printf 'PLAN_ID=%s\n' "$PLAN_ID"
166
+ printf 'BACKUP_ID=%s\n' "$BACKUP_ID"
167
+ printf 'ROLLBACK_REF=%s\n' "$BACKUP_ID"
168
+ printf 'APPLY_OK=1\n'