Files
Jyotisha/deploy/rehearse-production-migrations.sh
T
jesse-uxandGrok (agent loop) 00e6efdbfa 发布可以跟上只多了文档的测试环境头
四个生产工作流改用同一条发布修订:最近一次代码门禁成功的提交可以发布,后面只多文档不再挡住。生产环境拒绝测试验证码。63 个还没上生产的迁移写了分级、演练脚本和首发步骤。没有点任何生产按钮。

自测:发布修订 7 通过,演练安全 3 通过,tsc 0 错,lint 0 error。本机空库演练在服务角色读人物表处按设计停止(退出码 4,其他人物已确认行数为 0)。

Co-Authored-By: Grok (agent loop)
2026-10-14 10:24:40 +08:00

330 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
# Rehearse pending production migrations on a disposable database.
# The product runs this on the production host, or locally with
# JYOTISHA_REHEARSAL_LOCAL=1. There is no fifth Gitea workflow for it.
# Never restores into or migrates the database named jyotisha.
set -euo pipefail
set +x
umask 077
export LC_ALL=C
STOP_BEFORE="20260925030000_chart_subject_trust_and_limit.sql"
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
frontend_dir="$repo_root/frontend"
refuse() {
echo "$1" >&2
exit 1
}
database_name_from_url() {
local rest="${1#postgresql://}"
rest="${rest#*@}"
local path="${rest#*/}"
path="${path%%\?*}"
printf '%s' "$path"
}
assert_rehearse_name() {
local name="$1"
[[ "$name" != "jyotisha" ]] || refuse "refusing to touch the production database"
[[ "$name" =~ ^rehearse_[0-9a-z_]+$ ]] || refuse "rehearsal database name must match rehearse_<suffix>"
}
copy_batch() {
local dest="$1" mode="$2" dir file base
mkdir -p "$dest"
for dir in "$frontend_dir/db/migrations" "$frontend_dir/supabase/migrations"; do
[ -d "$dir" ] || continue
for file in "$dir"/*.sql; do
[ -f "$file" ] || continue
base="$(basename "$file")"
if [ "$mode" = before ] && [[ ! "$base" < "$STOP_BEFORE" ]]; then
continue
fi
cp -p "$file" "$dest/$base"
done
done
}
count_sql() {
find "$1" -maxdepth 1 -type f -name '*.sql' | wc -l | tr -d ' '
}
print_safe_log() {
local log="$1" line
while IFS= read -r line; do
case "$line" in
applied\ *.sql|already\ applied\ *.sql)
printf '%s\n' "$line"
;;
notice\ *)
if [[ "$line" =~ ^notice\ [A-Za-z0-9_.:=[:space:]-]+$ ]]; then
printf '%s\n' "$line"
else
echo "notice_redacted=1"
fi
;;
migration\ failed:*|migration\ file\ missing:*|migration\ checksum\ mismatch:*|duplicate\ migration\ filename:*|unable\ to\ read\ migrations\ directory|database\ migration\ failed)
printf '%s\n' "$line" >&2
;;
esac
done <"$log"
}
run_node_migrator() {
local batch="$1" log="$2" status
set +e
(
cd "$frontend_dir"
MIGRATIONS_DIRECTORY="$batch" node scripts/db-migrate.mjs
) >"$log" 2>&1
status=$?
set -e
print_safe_log "$log"
[ "$status" -eq 0 ] || refuse "rehearsal migration failed"
}
run_node_sql() {
local kind="$1"
(
cd "$frontend_dir"
node --input-type=module - "$kind" <<'PY'
import pg from "pg";
const kind = process.argv[2];
const source = process.env.SCHEMA_DATABASE_URL;
if (!source || !source.startsWith("postgresql://")) process.exit(2);
const url = new URL(source);
const name = url.pathname.replace(/^\//, "").split("/")[0];
if (!/^rehearse_[0-9a-z_]+$/.test(name) || name === "jyotisha") process.exit(2);
const client = new pg.Client({ connectionString: source });
await client.connect();
if (kind === "count") {
const result = await client.query(
"select count(*)::text as n from public.chart_profiles where role = 'other' and (birth_time_status in ('accepted','confirmed') or active_birth_time is not null or active_birth_date is not null or active_birth_timezone_offset is not null)",
);
const count = result.rows[0]?.n ?? "";
if (!/^[0-9]+$/.test(count)) process.exit(2);
process.stdout.write(count);
} else if (kind === "smoke") {
const checks = [
["authenticated", "public.profiles"],
["authenticated", "public.chat_sessions"],
["service_role", "public.chart_profiles"],
];
for (const [role, table] of checks) {
const result = await client.query(
"select has_table_privilege($1, $2, 'SELECT') as ok",
[role, table],
);
process.stdout.write(`${role} ${table} ${result.rows[0].ok}\n`);
}
} else if (kind === "drop") {
await client.end();
const admin = new URL(source);
admin.pathname = "/postgres";
const dropper = new pg.Client({ connectionString: admin.toString() });
await dropper.connect();
await dropper.query(`drop database if exists ${name} with (force)`);
await dropper.end();
process.exit(0);
} else {
process.exit(2);
}
await client.end();
PY
)
}
drop_rehearse_database() {
[ "${REHEARSAL_CREATED:-}" = 1 ] || return 0
REHEARSAL_CREATED=0
run_node_sql drop >/dev/null || echo "rehearsal database could not be removed" >&2
}
local_mode=0
if [ "${JYOTISHA_REHEARSAL_LOCAL:-}" = 1 ]; then
local_mode=1
fi
if [ "$local_mode" -eq 1 ]; then
: "${JYOTISHA_REHEARSAL_DATABASE_URL:?JYOTISHA_REHEARSAL_DATABASE_URL is required}"
[[ "$JYOTISHA_REHEARSAL_DATABASE_URL" == postgresql://* ]] || refuse "rehearsal URL must be a PostgreSQL URL"
database_name="$(database_name_from_url "$JYOTISHA_REHEARSAL_DATABASE_URL")"
assert_rehearse_name "$database_name"
export SCHEMA_DATABASE_URL="$JYOTISHA_REHEARSAL_DATABASE_URL"
unset JYOTISHA_REHEARSAL_DATABASE_URL
else
: "${DEPLOY_PATH:?DEPLOY_PATH is required}"
[ "$DEPLOY_PATH" = "/opt/jyotisha-production" ] || refuse "unexpected production path"
database_name="rehearse_$(date -u +%Y%m%d%H%M%S)"
assert_rehearse_name "$database_name"
fi
work_dir="$(mktemp -d "${TMPDIR:-/tmp}/jyotisha-rehearse.XXXXXX")"
cleanup_work() {
drop_rehearse_database
rm -rf -- "$work_dir"
}
trap cleanup_work EXIT HUP INT TERM
copy_batch "$work_dir/before" before
copy_batch "$work_dir/all" all
before_count="$(count_sql "$work_dir/before")"
all_count="$(count_sql "$work_dir/all")"
if [ -e "$work_dir/before/$STOP_BEFORE" ]; then
refuse "stop file leaked into the first batch"
fi
[ -f "$work_dir/all/$STOP_BEFORE" ] || refuse "stop file is missing from the migration set"
if [ "${JYOTISHA_REHEARSAL_PLAN:-}" = 1 ]; then
trap - EXIT HUP INT TERM
rm -rf -- "$work_dir"
printf 'rehearsal_plan=1\n'
printf 'database_name=%s\n' "$database_name"
printf 'batch_before=%s\n' "$before_count"
printf 'batch_all=%s\n' "$all_count"
printf 'stop_before=%s\n' "$STOP_BEFORE"
printf 'stop_in_before=no\n'
exit 0
fi
if [ "$local_mode" -eq 0 ]; then
: "${RECOVERY_DUMP:?RECOVERY_DUMP is required}"
[ -f "$RECOVERY_DUMP" ] && [ ! -L "$RECOVERY_DUMP" ] || refuse "recovery dump is missing or unsafe"
case "$RECOVERY_DUMP" in
"$DEPLOY_PATH"/backups/*.dump.enc) ;;
*) refuse "recovery dump must be an encrypted file under the production backup directory" ;;
esac
environment_file="$DEPLOY_PATH/.env.production.database"
[ -f "$environment_file" ] && [ ! -L "$environment_file" ] || refuse "production database environment file is missing or unsafe"
state_directory="$DEPLOY_PATH/.state"
backup_directory="$DEPLOY_PATH/backups"
lock_file="$state_directory/mutation.lock"
[ -d "$state_directory" ] && [ ! -L "$state_directory" ] || refuse "production state directory is unsafe"
[ -d "$backup_directory" ] && [ ! -L "$backup_directory" ] || refuse "production backup directory is unsafe"
exec 9>"$lock_file"
flock -n 9 || {
echo "another production mutation holds the host lock" >&2
exit 75
}
set -a
# shellcheck disable=SC1090
. "$environment_file"
set +a
: "${POSTGRES_DB:?}" "${POSTGRES_USER:?}" "${POSTGRES_PASSWORD:?}" "${PRODUCTION_BACKUP_ENCRYPTION_KEY:?}"
[ "$POSTGRES_DB" = "jyotisha" ] || refuse "unexpected production database name"
usage_percent="$(df -Pk "$backup_directory" | awk 'NR == 2 {gsub(/%/, "", $5); print $5}')"
[[ "$usage_percent" =~ ^[0-9]+$ ]] && (( usage_percent < 70 )) || refuse "production backup disk usage must remain below 70 percent"
mapfile -t postgres_containers < <(
sudo -n docker ps -q \
--filter 'label=com.docker.compose.project=jyotisha-production' \
--filter 'label=com.docker.compose.service=postgres'
)
[ "${#postgres_containers[@]}" -eq 1 ] || refuse "expected exactly one running production PostgreSQL container"
postgres_container="${postgres_containers[0]}"
mapfile -t web_containers < <(
sudo -n docker ps -q \
--filter 'label=com.docker.compose.project=jyotisha-production' \
--filter 'label=com.docker.compose.service=web'
)
[ "${#web_containers[@]}" -eq 1 ] || refuse "expected exactly one running production web container"
web_container="${web_containers[0]}"
web_image="$(sudo -n docker inspect --format '{{.Image}}' "$web_container")"
[[ "$web_image" =~ ^sha256:[0-9a-f]{64}$ ]] || refuse "production web image identity is unsafe"
web_network="$(sudo -n docker inspect --format '{{range $name, $net := .NetworkSettings.Networks}}{{$name}}{{"\n"}}{{end}}' "$web_container" | head -n 1)"
[ -n "$web_network" ] || refuse "production web network is missing"
sudo -n docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
psql -U "$POSTGRES_USER" -d postgres -v ON_ERROR_STOP=1 \
-c "CREATE DATABASE \"$database_name\";" >/dev/null
REHEARSAL_CREATED=1
openssl enc -d -aes-256-cbc -pbkdf2 \
-pass env:PRODUCTION_BACKUP_ENCRYPTION_KEY -in "$RECOVERY_DUMP" |
sudo -n docker exec -i -e PGPASSWORD="$POSTGRES_PASSWORD" "$postgres_container" \
pg_restore -U "$POSTGRES_USER" -d "$database_name" \
--no-owner --no-acl --exit-on-error
unset PRODUCTION_BACKUP_ENCRYPTION_KEY
# The URL is captured only into the environment, never echoed.
SCHEMA_DATABASE_URL="$(python3 - "$POSTGRES_USER" "$POSTGRES_PASSWORD" "$database_name" <<'PY'
import sys, urllib.parse
user, password, name = sys.argv[1:]
if name == "jyotisha" or not name.startswith("rehearse_"):
sys.exit(2)
print("postgresql://{user}:{password}@postgres:5432/{name}?options=-c%20role%3Dschema_owner".format(
user=urllib.parse.quote(user, safe=""),
password=urllib.parse.quote(password, safe=""),
name=name,
))
PY
)"
unset POSTGRES_PASSWORD
export SCHEMA_DATABASE_URL
run_batch() {
local batch="$1" log="$2" status
set +e
sudo -n docker run --rm --network "$web_network" \
-e SCHEMA_DATABASE_URL \
-e MIGRATIONS_DIRECTORY=/migrations \
-v "$batch:/migrations:ro" \
-v "$frontend_dir/scripts/db-migrate.mjs:/app/frontend/scripts/db-migrate.rehearse.mjs:ro" \
-w /app/frontend \
--entrypoint node \
"$web_image" scripts/db-migrate.rehearse.mjs >"$log" 2>&1
status=$?
set -e
return "$status"
}
else
REHEARSAL_CREATED=1
run_batch() {
run_node_migrator "$1" "$2"
return 0
}
fi
echo "rehearsal_database=$database_name"
echo "batch_before=$before_count"
echo "stop_before=$STOP_BEFORE"
set +e
run_batch "$work_dir/before" "$work_dir/before.log"
before_status=$?
set -e
if [ "$local_mode" -eq 0 ]; then
print_safe_log "$work_dir/before.log"
fi
[ "$before_status" -eq 0 ] || refuse "rehearsal migration failed"
other_rows="$(run_node_sql count)" || refuse "could not count other-person profiles before the stop file"
echo "other_person_accepted_rows=$other_rows"
if [ "$other_rows" != 0 ]; then
echo "stop: $STOP_BEFORE would change $other_rows other-person profiles; production was not changed" >&2
exit 1
fi
echo "batch_all=$all_count"
set +e
run_batch "$work_dir/all" "$work_dir/all.log"
all_status=$?
set -e
if [ "$local_mode" -eq 0 ]; then
print_safe_log "$work_dir/all.log"
fi
[ "$all_status" -eq 0 ] || refuse "rehearsal migration failed"
smoke_file="$work_dir/smoke.txt"
if ! run_node_sql smoke >"$smoke_file"; then
refuse "rehearsal smoke could not check role privileges"
fi
cat "$smoke_file"
if ! grep -q '^authenticated public.profiles true$' "$smoke_file"; then
refuse "web role cannot read profiles"
fi
if ! grep -q '^authenticated public.chat_sessions true$' "$smoke_file"; then
refuse "web role cannot read chat sessions"
fi
if ! grep -q '^service_role public.chart_profiles true$' "$smoke_file"; then
echo "stop: service_role still cannot read chart_profiles (BUG-1063 grant is not in this batch); do not migrate production" >&2
exit 4
fi
echo "rehearsal_ok=1"
trap - EXIT HUP INT TERM
drop_rehearse_database
rm -rf -- "$work_dir"