四个生产工作流改用同一条发布修订:最近一次代码门禁成功的提交可以发布,后面只多文档不再挡住。生产环境拒绝测试验证码。63 个还没上生产的迁移写了分级、演练脚本和首发步骤。没有点任何生产按钮。 自测:发布修订 7 通过,演练安全 3 通过,tsc 0 错,lint 0 error。本机空库演练在服务角色读人物表处按设计停止(退出码 4,其他人物已确认行数为 0)。 Co-Authored-By: Grok (agent loop)
330 lines
12 KiB
Bash
Executable File
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"
|