@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,197 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Create a real Postgres backup (pg_dump) with checksum + metadata.
|
|
3
|
+
# Never prints credentials. Never overwrites without a new backup id.
|
|
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
|
+
# Dumps contain production data. Make every created directory/artifact owner-only.
|
|
11
|
+
umask 077
|
|
12
|
+
|
|
13
|
+
usage() {
|
|
14
|
+
cat <<'EOF'
|
|
15
|
+
Usage: backup-create.sh [--dir DIR]
|
|
16
|
+
|
|
17
|
+
Environment:
|
|
18
|
+
DATABASE_URL Required. Postgres connection string for the active database.
|
|
19
|
+
|
|
20
|
+
Writes:
|
|
21
|
+
DIR/<id>.sql plain-format pg_dump
|
|
22
|
+
DIR/<id>.meta.json checksum, counts, timestamps, source db name
|
|
23
|
+
DIR/<id>.verified.json read-only evidence binding the dump and metadata
|
|
24
|
+
Prints backup id on stdout (last line: BACKUP_ID=<id>)
|
|
25
|
+
EOF
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
BACKUP_DIR="${CLEARANCE_BACKUP_DIR:-$ROOT/.clearance/backups}"
|
|
29
|
+
while [[ $# -gt 0 ]]; do
|
|
30
|
+
case "$1" in
|
|
31
|
+
--dir) BACKUP_DIR="$2"; shift 2 ;;
|
|
32
|
+
-h|--help) usage; exit 0 ;;
|
|
33
|
+
*) die "unknown argument: $1" ;;
|
|
34
|
+
esac
|
|
35
|
+
done
|
|
36
|
+
|
|
37
|
+
require_pg_client
|
|
38
|
+
require_cmd node
|
|
39
|
+
require_cmd openssl
|
|
40
|
+
URL="$(resolve_database_url)"
|
|
41
|
+
require_database_url "$URL"
|
|
42
|
+
|
|
43
|
+
ACTIVE_DB="$(db_name_from_url "$URL")"
|
|
44
|
+
assert_safe_ident "$ACTIVE_DB" "active database name"
|
|
45
|
+
|
|
46
|
+
mkdir -p "$BACKUP_DIR"
|
|
47
|
+
chmod 700 "$BACKUP_DIR"
|
|
48
|
+
# Unique id: bak_<utc>_<random>
|
|
49
|
+
ID="bak_$(date -u +%Y%m%dT%H%M%SZ)_$(openssl rand -hex 4)"
|
|
50
|
+
DUMP="$BACKUP_DIR/${ID}.sql"
|
|
51
|
+
META="$BACKUP_DIR/${ID}.meta.json"
|
|
52
|
+
VERIFIED="$BACKUP_DIR/${ID}.verified.json"
|
|
53
|
+
EVIDENCE_TMP="$(mktemp "${TMPDIR:-/tmp}/clearance-bak-evidence.XXXXXX")"
|
|
54
|
+
SNAPSHOT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/clearance-bak-snapshot.XXXXXX")"
|
|
55
|
+
SNAPSHOT_IN="$SNAPSHOT_DIR/in"
|
|
56
|
+
SNAPSHOT_OUT="$SNAPSHOT_DIR/out"
|
|
57
|
+
SNAPSHOT_OPEN=0
|
|
58
|
+
SNAPSHOT_PID=""
|
|
59
|
+
cleanup_tmp() {
|
|
60
|
+
local ec=$?
|
|
61
|
+
if [[ "$SNAPSHOT_OPEN" -eq 1 ]]; then
|
|
62
|
+
printf 'ROLLBACK;\n\\q\n' >&8 2>/dev/null || true
|
|
63
|
+
exec 8>&-
|
|
64
|
+
exec 9<&-
|
|
65
|
+
wait "$SNAPSHOT_PID" 2>/dev/null || true
|
|
66
|
+
fi
|
|
67
|
+
find "$SNAPSHOT_DIR" -type p -delete 2>/dev/null || true
|
|
68
|
+
rmdir "$SNAPSHOT_DIR" 2>/dev/null || true
|
|
69
|
+
find "$EVIDENCE_TMP" -type f -delete 2>/dev/null || true
|
|
70
|
+
if [[ "$ec" -ne 0 ]]; then
|
|
71
|
+
find "$DUMP" "$META" "$VERIFIED" -type f -delete 2>/dev/null || true
|
|
72
|
+
fi
|
|
73
|
+
}
|
|
74
|
+
trap cleanup_tmp EXIT
|
|
75
|
+
|
|
76
|
+
printf 'backup-create: dumping database=%s (url redacted=%s)\n' "$ACTIVE_DB" "$(redact_url "$URL")" >&2
|
|
77
|
+
|
|
78
|
+
# Hold one exported repeatable-read snapshot across both pg_dump and the
|
|
79
|
+
# resource/version evidence queries. Without this, ordinary writes between the
|
|
80
|
+
# dump and count collection can make a valid backup permanently unverifiable.
|
|
81
|
+
mkfifo "$SNAPSHOT_IN" "$SNAPSHOT_OUT"
|
|
82
|
+
psql --no-psqlrc -qAt -v ON_ERROR_STOP=1 "$URL" <"$SNAPSHOT_IN" >"$SNAPSHOT_OUT" &
|
|
83
|
+
SNAPSHOT_PID=$!
|
|
84
|
+
exec 8>"$SNAPSHOT_IN"
|
|
85
|
+
exec 9<"$SNAPSHOT_OUT"
|
|
86
|
+
SNAPSHOT_OPEN=1
|
|
87
|
+
printf 'BEGIN ISOLATION LEVEL REPEATABLE READ;\nSELECT pg_export_snapshot();\n' >&8
|
|
88
|
+
IFS= read -r SNAPSHOT_ID <&9 || die "could not export backup snapshot"
|
|
89
|
+
[[ "$SNAPSHOT_ID" =~ ^[0-9A-Fa-f-]+$ ]] || die "postgres returned an invalid backup snapshot id"
|
|
90
|
+
if [[ "${CLEARANCE_OPS_TESTING:-0}" == "1" ]]; then
|
|
91
|
+
if [[ -n "${CLEARANCE_BACKUP_TEST_SNAPSHOT_READY_FILE:-}" ]]; then
|
|
92
|
+
: >"$CLEARANCE_BACKUP_TEST_SNAPSHOT_READY_FILE"
|
|
93
|
+
fi
|
|
94
|
+
delay="${CLEARANCE_BACKUP_TEST_DELAY_AFTER_SNAPSHOT_SECONDS:-0}"
|
|
95
|
+
[[ "$delay" =~ ^[0-9]+([.][0-9]+)?$ ]] || die "invalid backup snapshot testing delay"
|
|
96
|
+
if [[ "$delay" != "0" ]]; then sleep "$delay"; fi
|
|
97
|
+
fi
|
|
98
|
+
|
|
99
|
+
# Real pg_dump — plain SQL for inspectability. Fail closed on any dump error.
|
|
100
|
+
# Uses matching major client via Docker when local pg_dump is older/newer than server.
|
|
101
|
+
pg_dump_to_file "$URL" "$DUMP" "$SNAPSHOT_ID"
|
|
102
|
+
|
|
103
|
+
printf '%s\n' \
|
|
104
|
+
'CREATE TEMP TABLE clearance_backup_counts(table_name text PRIMARY KEY, row_count bigint NOT NULL);' \
|
|
105
|
+
"SELECT format('INSERT INTO clearance_backup_counts SELECT %L, count(*) FROM %I.%I;', table_name, table_schema, table_name) FROM information_schema.tables WHERE table_schema='public' AND table_type='BASE TABLE' ORDER BY table_name;" \
|
|
106
|
+
'\gexec' \
|
|
107
|
+
"SELECT json_build_object('capturedAt', to_char(CURRENT_TIMESTAMP AT TIME ZONE 'UTC','YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"'), 'tables', COALESCE((SELECT json_object_agg(table_name,row_count ORDER BY table_name) FROM clearance_backup_counts),'{}'::json))::text;" \
|
|
108
|
+
"SELECT CASE WHEN to_regclass('public.clearance_management_snapshot') IS NULL THEN 'SELECT '''' || ''__CLEARANCE_APP_VERSION__'';' ELSE 'SELECT COALESCE((SELECT data->>''releaseVersion'' FROM clearance_management_snapshot WHERE id=1),'''') || ''__CLEARANCE_APP_VERSION__'';' END;" \
|
|
109
|
+
'\gexec' \
|
|
110
|
+
"SELECT '__CLEARANCE_SNAPSHOT_END__';" >&8
|
|
111
|
+
|
|
112
|
+
SNAPSHOT_EVIDENCE=""
|
|
113
|
+
APP_VERSION=""
|
|
114
|
+
while IFS= read -r line <&9; do
|
|
115
|
+
if [[ "$line" == "__CLEARANCE_SNAPSHOT_END__" ]]; then
|
|
116
|
+
break
|
|
117
|
+
elif [[ "$line" == *"__CLEARANCE_APP_VERSION__" ]]; then
|
|
118
|
+
APP_VERSION="${line%__CLEARANCE_APP_VERSION__}"
|
|
119
|
+
elif [[ "$line" == \{* ]]; then
|
|
120
|
+
SNAPSHOT_EVIDENCE="$line"
|
|
121
|
+
fi
|
|
122
|
+
done
|
|
123
|
+
[[ -n "$SNAPSHOT_EVIDENCE" ]] || die "could not collect evidence from the backup snapshot"
|
|
124
|
+
printf '%s\n' "$SNAPSHOT_EVIDENCE" >"$EVIDENCE_TMP"
|
|
125
|
+
printf 'COMMIT;\n\\q\n' >&8
|
|
126
|
+
exec 8>&-
|
|
127
|
+
exec 9<&-
|
|
128
|
+
wait "$SNAPSHOT_PID"
|
|
129
|
+
SNAPSHOT_OPEN=0
|
|
130
|
+
|
|
131
|
+
[[ -s "$DUMP" ]] || die "pg_dump produced empty file"
|
|
132
|
+
if ! grep -q 'PostgreSQL database dump' "$DUMP"; then
|
|
133
|
+
die "pg_dump output missing expected PostgreSQL dump header"
|
|
134
|
+
fi
|
|
135
|
+
grep -q 'PostgreSQL database dump complete' "$DUMP" \
|
|
136
|
+
|| die "pg_dump output missing completion marker"
|
|
137
|
+
|
|
138
|
+
CHECKSUM="$(sha256_file "$DUMP")"
|
|
139
|
+
BYTES="$(wc -c <"$DUMP" | tr -d ' ')"
|
|
140
|
+
PG_VER="$(grep -m1 'Dumped from database version' "$DUMP" || pg_dump --version | head -n1 || true)"
|
|
141
|
+
|
|
142
|
+
# Resource counts as flat map from evidence tables
|
|
143
|
+
COUNTS_JSON="$(node -e '
|
|
144
|
+
const fs=require("fs");
|
|
145
|
+
const j=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));
|
|
146
|
+
if (!j.tables || typeof j.tables !== "object" || Array.isArray(j.tables)) process.exit(2);
|
|
147
|
+
process.stdout.write(JSON.stringify(j.tables));
|
|
148
|
+
' "$EVIDENCE_TMP")" || die "failed to serialize required backup evidence"
|
|
149
|
+
|
|
150
|
+
CREATED="$(iso_now)"
|
|
151
|
+
cat >"$META" <<EOF
|
|
152
|
+
{
|
|
153
|
+
"id": $(json_escape "$ID"),
|
|
154
|
+
"format": "pg_dump_plain",
|
|
155
|
+
"createdAt": $(json_escape "$CREATED"),
|
|
156
|
+
"dumpFile": $(json_escape "$(basename "$DUMP")"),
|
|
157
|
+
"metaFile": $(json_escape "$(basename "$META")"),
|
|
158
|
+
"checksumSha256": $(json_escape "$CHECKSUM"),
|
|
159
|
+
"bytes": $BYTES,
|
|
160
|
+
"sourceDatabase": $(json_escape "$ACTIVE_DB"),
|
|
161
|
+
"applicationVersion": $(if [[ -n "$APP_VERSION" ]]; then json_escape "$APP_VERSION"; else printf 'null'; fi),
|
|
162
|
+
"pgDumpVersion": $(json_escape "$PG_VER"),
|
|
163
|
+
"resourceCounts": $COUNTS_JSON,
|
|
164
|
+
"tool": "scripts/backup-create.sh"
|
|
165
|
+
}
|
|
166
|
+
EOF
|
|
167
|
+
|
|
168
|
+
# Refuse writing secrets into meta
|
|
169
|
+
if grep -qiE 'password|postgresql://[^"]+:[^"]+@' "$META"; then
|
|
170
|
+
find "$DUMP" "$META" -type f -delete 2>/dev/null || true
|
|
171
|
+
die "metadata unexpectedly contained credential material; aborted"
|
|
172
|
+
fi
|
|
173
|
+
|
|
174
|
+
# Bind both artifacts into immutable local verification evidence. The evidence
|
|
175
|
+
# is owner-readable and non-writable; backup-verify checks every digest.
|
|
176
|
+
META_CHECKSUM="$(sha256_file "$META")"
|
|
177
|
+
RESOURCE_FINGERPRINT="$(schema_fingerprint_from_evidence "$EVIDENCE_TMP")"
|
|
178
|
+
cat >"$VERIFIED" <<EOF
|
|
179
|
+
{
|
|
180
|
+
"id": $(json_escape "$ID"),
|
|
181
|
+
"verifiedAt": $(json_escape "$(iso_now)"),
|
|
182
|
+
"dumpFile": $(json_escape "$(basename "$DUMP")"),
|
|
183
|
+
"metaFile": $(json_escape "$(basename "$META")"),
|
|
184
|
+
"dumpSha256": $(json_escape "$CHECKSUM"),
|
|
185
|
+
"metaSha256": $(json_escape "$META_CHECKSUM"),
|
|
186
|
+
"resourceEvidenceSha256": $(json_escape "$RESOURCE_FINGERPRINT"),
|
|
187
|
+
"checks": ["nonempty", "pg_dump_complete", "metadata_bound", "resource_counts_captured"]
|
|
188
|
+
}
|
|
189
|
+
EOF
|
|
190
|
+
chmod 600 "$DUMP" "$META"
|
|
191
|
+
chmod 400 "$VERIFIED"
|
|
192
|
+
|
|
193
|
+
printf 'backup-create: wrote %s (%s bytes, sha256=%s)\n' "$DUMP" "$BYTES" "$CHECKSUM" >&2
|
|
194
|
+
printf 'BACKUP_ID=%s\n' "$ID"
|
|
195
|
+
printf 'BACKUP_DUMP=%s\n' "$DUMP"
|
|
196
|
+
printf 'BACKUP_META=%s\n' "$META"
|
|
197
|
+
printf 'BACKUP_EVIDENCE=%s\n' "$VERIFIED"
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Restore a verified backup into an ISOLATED temporary database, compare evidence,
|
|
3
|
+
# and clean up. Never drops or overwrites the active database.
|
|
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: backup-restore-verify.sh --id ID [--dir DIR]
|
|
13
|
+
backup-restore-verify.sh --dump PATH --meta PATH
|
|
14
|
+
|
|
15
|
+
Restores into a temporary database name (clr_rv_*), queries management/runtime
|
|
16
|
+
tables when present, compares row counts to backup metadata evidence, drops the
|
|
17
|
+
temp database, and exits non-zero on mismatch.
|
|
18
|
+
|
|
19
|
+
Refuses to target the active database name under all circumstances.
|
|
20
|
+
EOF
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
BACKUP_DIR="${CLEARANCE_BACKUP_DIR:-$ROOT/.clearance/backups}"
|
|
24
|
+
ID=""
|
|
25
|
+
DUMP=""
|
|
26
|
+
META=""
|
|
27
|
+
KEEP_TEMP=0
|
|
28
|
+
|
|
29
|
+
while [[ $# -gt 0 ]]; do
|
|
30
|
+
case "$1" in
|
|
31
|
+
--id) ID="$2"; shift 2 ;;
|
|
32
|
+
--dir) BACKUP_DIR="$2"; shift 2 ;;
|
|
33
|
+
--dump) DUMP="$2"; shift 2 ;;
|
|
34
|
+
--meta) META="$2"; shift 2 ;;
|
|
35
|
+
--keep-temp) KEEP_TEMP=1; shift ;;
|
|
36
|
+
-h|--help) usage; exit 0 ;;
|
|
37
|
+
*) die "unknown argument: $1" ;;
|
|
38
|
+
esac
|
|
39
|
+
done
|
|
40
|
+
|
|
41
|
+
if [[ -n "$ID" ]]; then
|
|
42
|
+
DUMP="${DUMP:-$BACKUP_DIR/${ID}.sql}"
|
|
43
|
+
META="${META:-$BACKUP_DIR/${ID}.meta.json}"
|
|
44
|
+
fi
|
|
45
|
+
[[ -n "$DUMP" && -f "$DUMP" ]] || die "dump required"
|
|
46
|
+
[[ -n "$META" && -f "$META" ]] || die "meta required"
|
|
47
|
+
|
|
48
|
+
require_pg_client
|
|
49
|
+
require_cmd node
|
|
50
|
+
require_cmd openssl
|
|
51
|
+
URL="$(resolve_database_url)"
|
|
52
|
+
require_database_url "$URL"
|
|
53
|
+
ACTIVE_DB="$(db_name_from_url "$URL")"
|
|
54
|
+
ADMIN_URL="$(admin_url_from_url "$URL")"
|
|
55
|
+
|
|
56
|
+
# Integrity first
|
|
57
|
+
bash "$ROOT/scripts/backup-verify.sh" --dump "$DUMP" --meta "$META" >/dev/null
|
|
58
|
+
|
|
59
|
+
# Isolated temp DB name
|
|
60
|
+
SUFFIX="$(openssl rand -hex 4)"
|
|
61
|
+
TEMP_DB="clr_rv_${SUFFIX}"
|
|
62
|
+
assert_safe_ident "$TEMP_DB" "temp database"
|
|
63
|
+
assert_not_active_db "$TEMP_DB"
|
|
64
|
+
[[ "$TEMP_DB" != "$ACTIVE_DB" ]] || die "temp db collided with active db"
|
|
65
|
+
|
|
66
|
+
TEMP_URL="$(url_with_db "$URL" "$TEMP_DB")"
|
|
67
|
+
CREATED=0
|
|
68
|
+
RESTORED_EV=""
|
|
69
|
+
|
|
70
|
+
drop_temp_database() {
|
|
71
|
+
assert_not_active_db "$TEMP_DB"
|
|
72
|
+
psql_q "$ADMIN_URL" -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${TEMP_DB}' AND pid <> pg_backend_pid();" >/dev/null 2>&1 || true
|
|
73
|
+
psql_q "$ADMIN_URL" -c "DROP DATABASE IF EXISTS \"${TEMP_DB}\";" >/dev/null 2>&1 || return 1
|
|
74
|
+
local remains
|
|
75
|
+
remains="$(psql_q "$ADMIN_URL" -At -c "SELECT count(*) FROM pg_database WHERE datname = '${TEMP_DB}'")" || return 1
|
|
76
|
+
[[ "$remains" == "0" ]]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
cleanup() {
|
|
80
|
+
local ec=$?
|
|
81
|
+
[[ -z "$RESTORED_EV" ]] || rm -f "$RESTORED_EV"
|
|
82
|
+
if [[ "$KEEP_TEMP" -eq 1 ]]; then
|
|
83
|
+
printf 'backup-restore-verify: keeping temp database %s (active remains %s)\n' "$TEMP_DB" "$ACTIVE_DB" >&2
|
|
84
|
+
exit "$ec"
|
|
85
|
+
fi
|
|
86
|
+
if [[ "$CREATED" -eq 1 ]]; then
|
|
87
|
+
if ! drop_temp_database; then
|
|
88
|
+
printf 'backup-restore-verify: failed to clean up isolated database %s\n' "$TEMP_DB" >&2
|
|
89
|
+
ec=1
|
|
90
|
+
fi
|
|
91
|
+
fi
|
|
92
|
+
exit "$ec"
|
|
93
|
+
}
|
|
94
|
+
trap cleanup EXIT INT TERM
|
|
95
|
+
|
|
96
|
+
printf 'backup-restore-verify: creating isolated database %s (active=%s remains untouched)\n' \
|
|
97
|
+
"$TEMP_DB" "$ACTIVE_DB" >&2
|
|
98
|
+
|
|
99
|
+
psql_q "$ADMIN_URL" -c "CREATE DATABASE \"${TEMP_DB}\";"
|
|
100
|
+
CREATED=1
|
|
101
|
+
|
|
102
|
+
printf 'backup-restore-verify: restoring dump into isolated database\n' >&2
|
|
103
|
+
psql_restore_file "$TEMP_URL" "$DUMP"
|
|
104
|
+
|
|
105
|
+
# Evidence from restored DB
|
|
106
|
+
RESTORED_EV="$(mktemp "${TMPDIR:-/tmp}/clearance-restore-ev.XXXXXX")"
|
|
107
|
+
collect_db_evidence "$TEMP_URL" "$RESTORED_EV"
|
|
108
|
+
|
|
109
|
+
# Compare table evidence and the application release captured with the backup.
|
|
110
|
+
compare_backup_evidence "$META" "$RESTORED_EV"
|
|
111
|
+
EXPECTED_APP_VERSION="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.applicationVersion||"")' "$META")"
|
|
112
|
+
if [[ -n "$EXPECTED_APP_VERSION" ]]; then
|
|
113
|
+
require_application_release "$TEMP_URL" "$EXPECTED_APP_VERSION"
|
|
114
|
+
fi
|
|
115
|
+
rm -f "$RESTORED_EV"
|
|
116
|
+
RESTORED_EV=""
|
|
117
|
+
|
|
118
|
+
# Confirm active DB still exists and was not the restore target
|
|
119
|
+
STILL="$(psql_q "$ADMIN_URL" -At -c "SELECT 1 FROM pg_database WHERE datname = '${ACTIVE_DB}'")"
|
|
120
|
+
[[ "$STILL" == "1" ]] || die "active database disappeared — aborting (investigation required)"
|
|
121
|
+
assert_not_active_db "$TEMP_DB"
|
|
122
|
+
|
|
123
|
+
if [[ "$KEEP_TEMP" -eq 0 ]]; then
|
|
124
|
+
drop_temp_database || die "isolated restore succeeded but temporary database cleanup failed"
|
|
125
|
+
CREATED=0
|
|
126
|
+
fi
|
|
127
|
+
|
|
128
|
+
printf 'backup-restore-verify: OK (isolated restore compared; active db %s untouched; cleanup verified)\n' "$ACTIVE_DB"
|
|
129
|
+
printf 'RESTORE_VERIFIED=1\n'
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Verify a Clearance Postgres backup archive: checksum + dump structure + metadata.
|
|
3
|
+
# Does not connect to the live database and never restores.
|
|
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: backup-verify.sh --id ID [--dir DIR]
|
|
13
|
+
backup-verify.sh --dump PATH [--meta PATH] [--evidence PATH]
|
|
14
|
+
|
|
15
|
+
Inspects archive integrity only (checksum, pg_dump markers, metadata consistency).
|
|
16
|
+
Does not restore and does not touch the active database.
|
|
17
|
+
EOF
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
BACKUP_DIR="${CLEARANCE_BACKUP_DIR:-$ROOT/.clearance/backups}"
|
|
21
|
+
ID=""
|
|
22
|
+
DUMP=""
|
|
23
|
+
META=""
|
|
24
|
+
EVIDENCE=""
|
|
25
|
+
|
|
26
|
+
while [[ $# -gt 0 ]]; do
|
|
27
|
+
case "$1" in
|
|
28
|
+
--id) ID="$2"; shift 2 ;;
|
|
29
|
+
--dir) BACKUP_DIR="$2"; shift 2 ;;
|
|
30
|
+
--dump) DUMP="$2"; shift 2 ;;
|
|
31
|
+
--meta) META="$2"; shift 2 ;;
|
|
32
|
+
--evidence) EVIDENCE="$2"; shift 2 ;;
|
|
33
|
+
-h|--help) usage; exit 0 ;;
|
|
34
|
+
*) die "unknown argument: $1" ;;
|
|
35
|
+
esac
|
|
36
|
+
done
|
|
37
|
+
|
|
38
|
+
if [[ -n "$ID" ]]; then
|
|
39
|
+
DUMP="${DUMP:-$BACKUP_DIR/${ID}.sql}"
|
|
40
|
+
META="${META:-$BACKUP_DIR/${ID}.meta.json}"
|
|
41
|
+
EVIDENCE="${EVIDENCE:-$BACKUP_DIR/${ID}.verified.json}"
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
[[ -n "$DUMP" ]] || die "provide --id or --dump"
|
|
45
|
+
require_cmd node
|
|
46
|
+
[[ -f "$DUMP" ]] || die "dump file missing: $DUMP"
|
|
47
|
+
[[ -s "$DUMP" ]] || die "dump file empty: $DUMP"
|
|
48
|
+
|
|
49
|
+
if [[ -z "$META" && -n "$ID" ]]; then
|
|
50
|
+
META="$BACKUP_DIR/${ID}.meta.json"
|
|
51
|
+
fi
|
|
52
|
+
[[ -n "$META" && -f "$META" ]] || die "metadata file missing (expected alongside dump)"
|
|
53
|
+
if [[ -z "$EVIDENCE" ]]; then
|
|
54
|
+
EVIDENCE="${DUMP%.sql}.verified.json"
|
|
55
|
+
fi
|
|
56
|
+
[[ -f "$EVIDENCE" ]] || die "immutable verification evidence missing: $EVIDENCE"
|
|
57
|
+
|
|
58
|
+
# Checksum
|
|
59
|
+
ACTUAL="$(sha256_file "$DUMP")"
|
|
60
|
+
EXPECTED="$(node -e '
|
|
61
|
+
const fs=require("fs");
|
|
62
|
+
const j=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));
|
|
63
|
+
if(!j.checksumSha256) process.exit(2);
|
|
64
|
+
process.stdout.write(j.checksumSha256);
|
|
65
|
+
' "$META")" || die "metadata missing checksumSha256"
|
|
66
|
+
|
|
67
|
+
[[ "$ACTUAL" == "$EXPECTED" ]] || die "checksum mismatch (archive may be corrupted)"
|
|
68
|
+
|
|
69
|
+
# Archive structure inspection (plain pg_dump)
|
|
70
|
+
grep -q 'PostgreSQL database dump' "$DUMP" || die "archive missing PostgreSQL dump header"
|
|
71
|
+
grep -q 'PostgreSQL database dump complete' "$DUMP" || die "archive missing PostgreSQL dump completion marker"
|
|
72
|
+
# At least one CREATE or COPY / table structure signal
|
|
73
|
+
if ! grep -qE '^(CREATE TABLE|COPY |CREATE SCHEMA)' "$DUMP"; then
|
|
74
|
+
printf 'backup-verify: archive contains no table definitions (valid empty database)\n' >&2
|
|
75
|
+
fi
|
|
76
|
+
|
|
77
|
+
# Metadata consistency
|
|
78
|
+
node -e '
|
|
79
|
+
const fs=require("fs");
|
|
80
|
+
const meta=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));
|
|
81
|
+
const dumpBase=require("path").basename(process.argv[2]);
|
|
82
|
+
if(!meta.id) { console.error("meta missing id"); process.exit(1); }
|
|
83
|
+
if(meta.format !== "pg_dump_plain") { console.error("unsupported format"); process.exit(1); }
|
|
84
|
+
if(meta.dumpFile && meta.dumpFile !== dumpBase) {
|
|
85
|
+
console.error("meta.dumpFile does not match dump basename");
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
if(typeof meta.bytes === "number") {
|
|
89
|
+
const st=fs.statSync(process.argv[2]);
|
|
90
|
+
if(st.size !== meta.bytes) {
|
|
91
|
+
console.error("meta.bytes does not match dump size");
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Fail closed: meta must not embed credentials
|
|
96
|
+
const raw=fs.readFileSync(process.argv[1],"utf8");
|
|
97
|
+
if(/password\s*[:=]/i.test(raw) || /postgres(ql)?:\/\/[^/\s"]+:[^@\s"]+@/i.test(raw)) {
|
|
98
|
+
console.error("metadata appears to contain credentials");
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
' "$META" "$DUMP"
|
|
102
|
+
|
|
103
|
+
# Verification evidence is content-addressed. This catches metadata rewriting
|
|
104
|
+
# as well as dump corruption, even when only one checksum was updated.
|
|
105
|
+
node -e '
|
|
106
|
+
const fs=require("fs");
|
|
107
|
+
const path=require("path");
|
|
108
|
+
const crypto=require("crypto");
|
|
109
|
+
const ev=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));
|
|
110
|
+
const dump=process.argv[2];
|
|
111
|
+
const meta=process.argv[3];
|
|
112
|
+
const sha=(p)=>crypto.createHash("sha256").update(fs.readFileSync(p)).digest("hex");
|
|
113
|
+
if(!ev.id || !ev.verifiedAt || !Array.isArray(ev.checks)) process.exit(2);
|
|
114
|
+
if(ev.dumpFile!==path.basename(dump) || ev.metaFile!==path.basename(meta)) process.exit(3);
|
|
115
|
+
if(ev.dumpSha256!==sha(dump) || ev.metaSha256!==sha(meta)) process.exit(4);
|
|
116
|
+
if((fs.statSync(process.argv[1]).mode & 0o222)!==0) process.exit(5);
|
|
117
|
+
' "$EVIDENCE" "$DUMP" "$META" || die "immutable backup verification evidence mismatch"
|
|
118
|
+
|
|
119
|
+
printf 'backup-verify: OK id=%s sha256=%s\n' "$(basename "$DUMP" .sql)" "$ACTUAL"
|
|
120
|
+
printf 'BACKUP_VERIFIED=1\n'
|
|
121
|
+
printf 'BACKUP_EVIDENCE=%s\n' "$EVIDENCE"
|