Почему: backup.sh снимает только pg_dump -Fc одной БД, без ролей (globals). Проверено на временном кластере PG17 без ролей krona_*: pg_restore exit=1, 61 ошибка «role krona_migrator does not exist», данные есть, владелец — postgres. В пилоте роли, вероятно, создаются при инициализации контейнера (POSTGRES_USER, docker/postgres/init-app-role.sh) — без Docker не проверено.
20 KiB
K18 (codex/fix-pilot-backups a005c5e) и перенос арендатора (codex/fix-tenant-rehome c73bb3b) на восстановленной копии
PG17 17.11 локально, без Docker. Ветки исполнителей не менялись: отдельные detached worktree
/workspace/krona-staff-acceptance/diag-pilot-backups и diag-tenant-rehome.
1. Тесты Codex из ветки (моки docker/ssh/scp)
$ bash scripts/test-backup.sh; bash scripts/test-backup-pull.sh backup tests passed: syntax, CMS round-trip, pg_dump/OpenSSL/scp failures, no plaintext dump on disk (exit=0) backup pull tests passed: CMS+checksum, idempotence, stale/mismatch rejection, path validation, scp failure (exit=0)
2. Учебное восстановление: настоящий pg_dump → backup.sh → sha256 → расшифровка → pg_restore → отчёты
docker подменён шимом: 'docker compose exec -T postgres sh -eu -c ' выполняет локально
(pg_dump -U pgadmin -d krona_k18_src -Fc); источник — копия БД e2e (заглушка, посторонние арендаторы, данные e2e).
$ bash drill.sh == 1. источник: копия krona_acc_e2e (данные последнего e2e-прогона accept-branch) == 2. deploy/pilot/backup.sh (external-pull) backup=/server/backups/krona-staff-20260926T101055Z-1837466.dump.cms checksum=/server/backups/krona-staff-20260926T101055Z-1837466.dump.cms.sha256 off_host_copy=pending_pull exit=0 plaintext на сервере: 0 файлов первые байты (DER): 30 80 06 09 == 3. «перенос на ПК» (cp вместо ssh/scp — pull-pilot-backups.sh здесь не запускался) + sha256 krona-staff-20260926T101055Z-1837466.dump.cms: OK == 4. расшифровка ключом вне сервера + pg_restore в новую БД TABLE DATA в дампе: 28 pg_restore exit=0 == 5. restore-drill-report.sql: источник vs копия строк отчёта: 79; diff: идентичны RLS|27|t LEDGER||RUB|263568 LEDGER||RUB|22200 LEDGER||RUB|329705 schema_migrations: 11 staff_payroll_ledger owner=krona_migrator гранты krona_staff_app: 100 == 6. restore-drill-isolation.sql (krona_staff_app, NOBYPASSRLS) BEGIN SET SET source-employee-count|3 SET target-employee-count|0 ROLLBACK
3. Перенос заглушки на копии, интерфейс продукта как есть
$ bash rehome-on-copy.sh # DATABASE_REHOME_URL=<суперпользователь>@krona_k18_restore == dry-run: node scripts/rehome-tenant.mjs --dry-run --source --target exit=0 {"mode":"dry-run","source":"","target":"","tables":{"staff_attendance_events":"2","staff_audit_log":"2","staff_daily_metrics":"2","staff_employees":"3","staff_idempotency_keys":"2","staff_inbox":"2","staff_metric_definitions":"2","staff_notification_deliveries":"2","staff_org_unit_terms":"2","staff_org_units":"2","staff_outbox":"2","staff_pay_assignments":"2","staff_pay_rules":"2","staff_payouts":"2","staff_payroll_ledger":"3","staff_payroll_lines":"2","staff_payroll_runs":"2","staff_professions":"2","staff_push_subscriptions":"2","staff_standalone_employee_access":"2","staff_sync_change_log":"2","staff_sync_command_inbox":"2","staff_sync_device_checkpoints":"2","staff_sync_devices":"2","staff_timesheet_entries":"2","staff_work_items":"2","staff_work_sessions":"2"}}
dry-run: БД не изменилась == C6-интерфейс на этом же скрипте Tenant rehome stopped. Unknown option --from. No connection details or row contents were printed. exit=1 == apply без --backup-evidence Tenant rehome stopped. --backup-evidence is required for --apply. No connection details or row contents were printed. exit=1 == apply: --apply --source --target --actor --backup-evidence evidence.json exit=0 {"mode":"apply","source":"","target":"","tables":{"staff_attendance_events":"2","staff_audit_log":"3","staff_daily_metrics":"2","staff_employees":"3","staff_idempotency_keys":"2","staff_inbox":"2","staff_metric_definitions":"2","staff_notification_deliveries":"2","staff_org_unit_terms":"2","staff_org_units":"2","staff_outbox":"2","staff_pay_assignments":"2","staff_pay_rules":"2","staff_payouts":"2","staff_payroll_ledger":"3","staff_payroll_lines":"2","staff_payroll_runs":"2","staff_professions":"2","staff_push_subscriptions":"2","staff_standalone_employee_access":"2","staff_sync_change_log":"2","staff_sync_command_inbox":"2","staff_sync_device_checkpoints":"2","staff_sync_devices":"2","staff_timesheet_entries":"2","staff_work_items":"2","staff_work_sessions":"2"},"auditRowsAdded":1,"payrollLedger":[{"currency":"RUB","amountMinor":"263568"}]}
== сверка (restore-drill-report.sql до/после; счётчики по таблице/арендатору, RLS, ledger) -- строки с после: 0 -- прочие арендаторы до/после идентичны: да -- счётчики до vs после (кроме audit): совпадают -- audit: до 2, после 3 LEDGER||RUB|263568 LEDGER||RUB|22200 LEDGER||RUB|329705 до: LEDGER||RUB|263568 до: LEDGER||RUB|22200 до: LEDGER||RUB|329705 tenant.rehomed tenant_rehome FK после: 30 == krona_staff_app source-employee-count|0 target-employee-count|3 ERROR: permission denied for table staff_payroll_ledger == повторный apply Tenant rehome stopped. Target tenant already contains staff data; refusing merge. No connection details or row contents were printed. exit=1 повтор: БД не изменилась FK (определения + convalidated), md5 по всем FK: источник 54ee68489eced699e40a343f4ba4134e, копия после переноса 54ee68489eced699e40a343f4ba4134e
4. Сценарий [PILOT-REHOME] через диагностический адаптер C6 → c73bb3b (адаптер НЕ в продукте и НЕ в сценарии)
Вариант 1: адаптер только переводит флаги/переменные/формат вывода.
$ E2E_DATABASE_NAME=krona_diag_rehome_e2e node scripts/test/run-e2e.mjs ✓ tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME-SEED] сиды покрывают все таблицы с tenant_id (список из information_schema) 183ms × tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает 96ms → expected 'undefined' to be '328228' // Object.is equality
Вариант 2: адаптер дополнительно сам досчитывает SUM(amount_minor) для --dry-run (продукт её там не печатает).
✓ tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME-SEED] сиды покрывают все таблицы с tenant_id (список из information_schema) 177ms ✓ tests/e2e/60-pilot-rehome.e2e.ts > PILOT-REHOME: перенос заглушки арендатора на настоящий tenant > [PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает 460ms
5. Восстановление в «голый» кластер (ролей нет)
Тот же pg_dump -Fc (источник krona_k18_src, владелец таблиц krona_migrator, как в стенде e2e),
восстановление во временный кластер PG17 (initdb, порт 55439), где ролей krona_* нет:
$ createdb krona_staff (свежий кластер PG17, ролей krona_* нет) && pg_restore --exit-on-error -d krona_staff d.dump
pg_restore: error: could not execute query: ERROR: role "krona_migrator" does not exist
Command was: ALTER SCHEMA public OWNER TO krona_migrator;
exit=1
$ pg_restore без --exit-on-error (как по умолчанию)
exit=1
ошибок: 61
pg_restore: error: could not execute query: ERROR: role "krona_migrator" does not exist
154 # staff_employees: строки восстановлены
postgres # владелец staff_employees
0 # ролей krona_* в кластере
В пилоте владелец — POSTGRES_USER, а krona_staff_app создаёт docker/postgres/init-app-role.sh
при инициализации контейнера — там роли, вероятно, будут; без Docker не проверено.
Исходный код адаптера (вариант 2)
// ДИАГНОСТИЧЕСКИЙ АДАПТЕР (не коммитится в продукт, не часть сценария): переводит контракт C6
// (--from/--to/--dry-run/--json, DATABASE_ADMIN_URL, одна строка JSON с ledger_amount_minor_sum)
// в интерфейс codex/fix-tenant-rehome c73bb3b (--source/--target, --dry-run|--apply, --actor,
// --backup-evidence, DATABASE_REHOME_URL, многострочный JSON). Evidence — синтетическая.
import { spawnSync } from 'node:child_process';
import { writeFileSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
const a = process.argv.slice(2);
const get = (k) => a[a.indexOf(k) + 1];
const dry = a.includes('--dry-run');
const args = ['--source', get('--from'), '--target', get('--to')];
if (dry) args.unshift('--dry-run');
else {
const dir = mkdtempSync(path.join(tmpdir(), 'rehome-ev-'));
const sha = 'b'.repeat(64);
writeFileSync(
path.join(dir, 'e.json'),
JSON.stringify({
backup: {
format: 'pg_dump -Fc',
encrypted: true,
offHost: true,
sha256: sha,
createdAt: '2026-09-26T10:00:00Z',
},
restoreDrill: {
status: 'passed',
backupSha256: sha,
restoredAt: '2026-09-26T10:01:00Z',
verifiedAt: '2026-09-26T10:02:00Z',
isolatedDatabase: 'diag',
},
}),
);
args.unshift('--apply');
args.push('--actor', '01M3ACT0R000000000000000AA', '--backup-evidence', path.join(dir, 'e.json'));
}
const r = spawnSync(
process.execPath,
[path.join(import.meta.dirname, 'rehome-tenant.product.mjs'), ...args],
{
env: { ...process.env, DATABASE_REHOME_URL: process.env.DATABASE_ADMIN_URL },
encoding: 'utf8',
},
);
process.stderr.write(r.stderr);
if (r.status !== 0) process.exit(r.status ?? 1);
const rep = JSON.parse(r.stdout);
const out = {
tables: Object.fromEntries(Object.entries(rep.tables).map(([k, v]) => [k, Number(v)])),
};
if (rep.payrollLedger)
out.ledger_amount_minor_sum = rep.payrollLedger
.reduce((s, x) => s + BigInt(x.amountMinor), 0n)
.toString();
if (dry && !out.ledger_amount_minor_sum) {
// ВАРИАНТ 2: сумму досчитывает сам адаптер (продукт её в dry-run не печатает), чтобы проверить остальные шаги.
const pg = (await import('pg')).default;
const c = new pg.Client({ connectionString: process.env.DATABASE_ADMIN_URL });
await c.connect();
out.ledger_amount_minor_sum = (
await c.query(
'SELECT coalesce(sum(amount_minor),0)::text s FROM staff_payroll_ledger WHERE tenant_id=$1',
[get('--from')],
)
).rows[0].s;
await c.end();
}
console.log(JSON.stringify(out));
Исходный код drill.sh
#!/usr/bin/env bash
# K18: backup.sh (codex/fix-pilot-backups a005c5e) → шифрованный файл → проверка sha256 → расшифровка
# → pg_restore в новую БД → restore-drill-report.sql на источнике и копии → сравнение → isolation.
# Docker нет: `docker compose exec -T postgres sh -eu -c '<cmd>'` подменён шимом, который выполняет
# ту же команду pg_dump локально против PG17 (суперпользователь, как POSTGRES_USER в образе postgres).
set -euo pipefail
WT=/workspace/krona-staff-acceptance/diag-pilot-backups
PGBIN=/workspace/.mamba-root/envs/pg17/bin
export PGHOST=127.0.0.1 PGPORT=55433 PGUSER=pgadmin PGPASSWORD="$(cat /workspace/.pg17-secrets/admin.pw)"
psql() { "$PGBIN/psql" -X -v ON_ERROR_STOP=1 "$@"; }
T=$(mktemp -d /tmp/k18-drill.XXXXXX); trap 'rm -rf "$T"' EXIT
mkdir -p "$T/bin" "$T/server" "$T/pc" "$T/offhost-key"
echo "== 1. источник: копия krona_acc_e2e (данные последнего e2e-прогона accept-branch)"
psql -d postgres -q -c "DROP DATABASE IF EXISTS krona_k18_src" -c "DROP DATABASE IF EXISTS krona_k18_restore" \
-c "CREATE DATABASE krona_k18_src TEMPLATE krona_acc_e2e OWNER krona_migrator" \
-c "REVOKE ALL ON DATABASE krona_k18_src FROM PUBLIC" -c "GRANT CONNECT ON DATABASE krona_k18_src TO krona_staff_app"
cat > "$T/bin/docker" <<SHIM
#!/bin/sh
for last; do :; done
exec env PATH="$PGBIN:\$PATH" POSTGRES_USER=pgadmin POSTGRES_DB=krona_k18_src sh -eu -c "\$last"
SHIM
chmod +x "$T/bin/docker"
openssl req -x509 -newkey rsa:2048 -nodes -sha256 -days 1 -keyout "$T/offhost-key/recipient.key" \
-out "$T/server/recipient.crt" -subj '/CN=k18-drill' >/dev/null 2>&1
echo "== 2. deploy/pilot/backup.sh (external-pull)"
PATH="$T/bin:$PATH" BACKUP_DIRECTORY="$T/server/backups" BACKUP_RECIPIENT_CERT="$T/server/recipient.crt" \
BACKUP_TRANSFER_MODE=external-pull sh "$WT/deploy/pilot/backup.sh" | sed "s#$T#<tmp>#g"
echo "exit=$?"
f=$(ls "$T/server/backups"/*.dump.cms); n=$(basename "$f")
echo "plaintext на сервере: $(find "$T/server/backups" -type f ! -name '*.dump.cms' ! -name '*.dump.cms.sha256' | wc -l) файлов"
head -c 4 "$f" | od -An -tx1 | sed 's/^/первые байты (DER): /'
echo "== 3. «перенос на ПК» (cp вместо ssh/scp — pull-pilot-backups.sh здесь не запускался) + sha256"
cp "$f" "$f.sha256" "$T/pc/" && (cd "$T/pc" && sha256sum -c "$n.sha256")
sha=$(cut -d' ' -f1 "$T/pc/$n.sha256")
echo "== 4. расшифровка ключом вне сервера + pg_restore в новую БД"
openssl cms -decrypt -binary -inform DER -in "$T/pc/$n" -recip "$T/server/recipient.crt" \
-inkey "$T/offhost-key/recipient.key" -out "$T/pc/restore.dump"
"$PGBIN/pg_restore" -l "$T/pc/restore.dump" | grep -c "TABLE DATA" | sed 's/^/TABLE DATA в дампе: /'
psql -d postgres -q -c "CREATE DATABASE krona_k18_restore TEMPLATE template0 OWNER krona_migrator" \
-c "REVOKE ALL ON DATABASE krona_k18_restore FROM PUBLIC" -c "GRANT CONNECT ON DATABASE krona_k18_restore TO krona_staff_app"
"$PGBIN/pg_restore" --exit-on-error -d krona_k18_restore "$T/pc/restore.dump"; echo "pg_restore exit=$?"
echo "== 5. restore-drill-report.sql: источник vs копия"
psql -d krona_k18_src -At -f "$WT/scripts/restore-drill-report.sql" > "$T/src.txt"
psql -d krona_k18_restore -At -f "$WT/scripts/restore-drill-report.sql" > "$T/restore.txt"
echo "строк отчёта: $(wc -l < "$T/src.txt"); diff:"; diff "$T/src.txt" "$T/restore.txt" && echo "идентичны"
grep -E '^(RLS|LEDGER)' "$T/restore.txt" | sed -E 's/[0-9A-HJKMNP-TV-Z]{26}/<ulid>/g'
psql -d krona_k18_restore -At -c "select count(*) from schema_migrations" | sed 's/^/schema_migrations: /'
psql -d krona_k18_restore -At -c "select tablename||' owner='||tableowner from pg_tables where schemaname='public' and tablename='staff_payroll_ledger'"
psql -d krona_k18_restore -At -c "select count(*) from information_schema.role_table_grants where grantee='krona_staff_app'" | sed 's/^/гранты krona_staff_app: /'
echo "== 6. restore-drill-isolation.sql (krona_staff_app, NOBYPASSRLS)"
psql -d krona_k18_restore -At -f "$WT/scripts/restore-drill-isolation.sql"
printf '%s\n' "$sha" > /tmp/ab/k18/backup.sha256
Исходный код rehome-on-copy.sh
#!/usr/bin/env bash
# TASK-CODEX-2 п.2.2 на восстановленной копии (krona_k18_restore из drill.sh), интерфейс продукта c73bb3b как есть.
set -uo pipefail
WT=/workspace/krona-staff-acceptance/diag-tenant-rehome
BK=/workspace/krona-staff-acceptance/diag-pilot-backups
PGBIN=/workspace/.mamba-root/envs/pg17/bin
export PGHOST=127.0.0.1 PGPORT=55433 PGUSER=pgadmin PGPASSWORD="$(cat /workspace/.pg17-secrets/admin.pw)"
DB=krona_k18_restore
URL="postgres://pgadmin:${PGPASSWORD}@127.0.0.1:55433/$DB"
STUB=01J00000000000000000000000; TARGET=01M2R6AC5652NPT3MG34DKN4ZP
psql() { "$PGBIN/psql" -X -v ON_ERROR_STOP=1 "$@"; }
mask() { sed -E "s/$STUB/<STUB>/g; s/$TARGET/<TARGET>/g; s/[0-9A-HJKMNP-TV-Z]{26}/<other>/g"; }
cd "$WT"
psql -d $DB -At -f "$BK/scripts/restore-drill-report.sql" > /tmp/ab/k18/before.txt
echo "== dry-run: node scripts/rehome-tenant.mjs --dry-run --source <STUB> --target <TARGET>"
DATABASE_REHOME_URL="$URL" node scripts/rehome-tenant.mjs --dry-run --source $STUB --target $TARGET > /tmp/ab/k18/dry.json; echo "exit=$?"
mask < /tmp/ab/k18/dry.json | tr -d '\n ' | cut -c1-900; echo
psql -d $DB -At -f "$BK/scripts/restore-drill-report.sql" > /tmp/ab/k18/after-dry.txt
diff -q /tmp/ab/k18/before.txt /tmp/ab/k18/after-dry.txt && echo "dry-run: БД не изменилась"
echo "== C6-интерфейс на этом же скрипте"
DATABASE_ADMIN_URL="$URL" node scripts/rehome-tenant.mjs --from $STUB --to $TARGET --dry-run --json; echo "exit=$?"
echo "== apply без --backup-evidence"
DATABASE_REHOME_URL="$URL" node scripts/rehome-tenant.mjs --apply --source $STUB --target $TARGET --actor 01M3ACT0R000000000000000AA; echo "exit=$?"
sha=$(cat /tmp/ab/k18/backup.sha256)
cat > /tmp/ab/k18/evidence.json <<JSON
{"backup":{"format":"pg_dump -Fc","encrypted":true,"offHost":true,"sha256":"$sha","createdAt":"2026-09-26T10:10:55Z"},
"restoreDrill":{"status":"passed","backupSha256":"$sha","restoredAt":"2026-09-26T10:11:00Z","verifiedAt":"2026-09-26T10:11:05Z","isolatedDatabase":"$DB"}}
JSON
echo "== apply: --apply --source <STUB> --target <TARGET> --actor <ulid> --backup-evidence evidence.json"
DATABASE_REHOME_URL="$URL" node scripts/rehome-tenant.mjs --apply --source $STUB --target $TARGET --actor 01M3ACT0R000000000000000AA --backup-evidence /tmp/ab/k18/evidence.json > /tmp/ab/k18/apply.json; echo "exit=$?"
mask < /tmp/ab/k18/apply.json | tr -d '\n ' | cut -c1-1200; echo
psql -d $DB -At -f "$BK/scripts/restore-drill-report.sql" > /tmp/ab/k18/after.txt
echo "== сверка (restore-drill-report.sql до/после; счётчики по таблице/арендатору, RLS, ledger)"
echo "-- строки с <STUB> после: $(grep -c "|$STUB|" /tmp/ab/k18/after.txt)"
echo "-- прочие арендаторы до/после идентичны: $(diff <(grep -v -e "|$STUB|" -e "|$TARGET|" /tmp/ab/k18/before.txt) <(grep -v -e "|$STUB|" -e "|$TARGET|" /tmp/ab/k18/after.txt) >/dev/null && echo да || echo НЕТ)"
echo "-- счётчики <STUB> до vs <TARGET> после (кроме audit):"
diff <(grep "|$STUB|" /tmp/ab/k18/before.txt | grep -v '^staff_audit_log' | sed "s/$STUB/X/") <(grep "|$TARGET|" /tmp/ab/k18/after.txt | grep -v '^staff_audit_log' | sed "s/$TARGET/X/") && echo " совпадают"
echo "-- audit: до $(grep "^staff_audit_log|$STUB|" /tmp/ab/k18/before.txt | cut -d'|' -f3), после $(grep "^staff_audit_log|$TARGET|" /tmp/ab/k18/after.txt | cut -d'|' -f3)"
grep "^LEDGER" /tmp/ab/k18/after.txt | mask; grep "^LEDGER" /tmp/ab/k18/before.txt | mask | sed 's/^/до: /'
psql -d $DB -At -c "select action||' '||entity_type from staff_audit_log where tenant_id='$TARGET' and action like 'tenant.%'"
psql -d $DB -At -c "select count(*) from pg_constraint where contype='f' and connamespace='public'::regnamespace" | sed 's/^/FK после: /'
echo "== krona_staff_app"
psql -d $DB -At -f "$BK/scripts/restore-drill-isolation.sql" | grep count
psql -d $DB -At -c "BEGIN; SET LOCAL ROLE krona_staff_app; SET LOCAL app.tenant_id='$TARGET'; UPDATE staff_payroll_ledger SET amount_minor=amount_minor+1;" 2>&1 | tail -1
echo "== повторный apply"
DATABASE_REHOME_URL="$URL" node scripts/rehome-tenant.mjs --apply --source $STUB --target $TARGET --actor 01M3ACT0R000000000000000AA --backup-evidence /tmp/ab/k18/evidence.json; echo "exit=$?"
psql -d $DB -At -f "$BK/scripts/restore-drill-report.sql" | diff -q - /tmp/ab/k18/after.txt && echo "повтор: БД не изменилась"