@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.
- package/dist/index.d.mts +241 -52
- package/dist/index.mjs +1169 -10462
- 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 +66 -0
- package/dist/ops/deploy/upgrades/steps/0.2.1/apply.sh +66 -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/package.json +7 -7
|
@@ -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'
|
|
@@ -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'
|