# 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_* нет: ```text $ 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) ```js // ДИАГНОСТИЧЕСКИЙ АДАПТЕР (не коммитится в продукт, не часть сценария): переводит контракт 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 ```bash #!/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 ''` подменён шимом, который выполняет # ту же команду 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" </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##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}//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 ```bash #!/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//g; s/$TARGET//g; s/[0-9A-HJKMNP-TV-Z]{26}//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 --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 < --target --actor --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 "-- строки с после: $(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 "-- счётчики до vs после (кроме 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 "повтор: БД не изменилась" ``` ## 6. Ветки сдвинулись во время приёмки (17:02 и 17:07 NOVT) После `git fetch` (~17:13 NOVT): `codex/fix-tenant-rehome` c73bb3b → **d52ecf3** (`fix: accept audit harness tenant rehome CLI aliases`, 17:02:16 +0700), `codex/fix-pilot-backups` a005c5e → **b022885** (`ops: schedule home pilot backup and retain verified PC copies`, 17:07:36 +0700). Приёмка повторена по всем 7 веткам: [`round3-accept-f7d3c73-rerun.md`](round3-accept-f7d3c73-rerun.md) (стенд a6a1b5e, логи `tests/e2e/evidence/accept/2026-09-26T10-14-42/`). ### 6.1 d52ecf3: что изменилось в интерфейсе `git diff c73bb3b origin/codex/fix-tenant-rehome -- scripts/rehome-tenant.mjs`: добавлены `--from`/`--to` (синонимы `--source`/`--target`), `--json` (принимается, вывод и так JSON), `DATABASE_ADMIN_URL` (первым, дальше `DATABASE_REHOME_URL`, `DATABASE_MIGRATION_URL`); `result.mode ??= 'dry-run'` — **без флага теперь dry-run**, а не ошибка; `--apply` по-прежнему требует `--actor` и `--backup-evidence`. Вывод остался многострочным (`JSON.stringify(…, null, 2)`), счётчики — строки, в dry-run суммы журнала нет. ### 6.2 [PILOT-REHOME] на d52ecf3, как есть (accept-branch) ``` × … [PILOT-REHOME] --dry-run, перенос админом, сверка, повторный запуск отказывает 73ms AssertionError: dry-run без JSON-отчёта: { "mode": "dry-run", "source": "01J00000000000000000000000", "target": "01M2R6AC5652NPT3MG34DKN4ZP", "tables": { "staff_attendance_events": "2", … } } : expected null not to be null ❯ tests/e2e/60-pilot-rehome.e2e.ts:468:70 ``` Интерфейс C6: «`--json`: последней строкой stdout — `{…}`»; продукт печатает JSON на нескольких строках. ### 6.3 Диагностика: что упадёт следующим (правки только в неотслеживаемой копии сценария в worktree приёмки, потом откатил) `node scripts/test/run-e2e.mjs` в `wt-codex-fix-tenant-rehome` (d52ecf3), каждый шаг добавляет одну правку к предыдущему: | Правка в копии сценария | Итог `[PILOT-REHOME]` | | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | парсер принимает весь stdout как JSON | `expected { staff_attendance_events: '2', …(26) } to deeply equal { staff_attendance_events: 2, …(26) }` | | + счётчики через `Number()` (это **ошибка сценария**, исправлена в d477dde) | `expected 'undefined' to be '375888'` — в dry-run нет `ledger_amount_minor_sum` | | + убрана проверка суммы в dry-run | `под заглушкой остались строки: expected [ 'staff_attendance_events', …(26) ] to deeply equal []` — вызов без флага (`--json`) теперь dry-run, exit 0, переноса нет | После отката: `diff -q tests/e2e/60-pilot-rehome.e2e.ts /workspace/krona-staff/tests/e2e/60-pilot-rehome.e2e.ts && echo restored` → `restored`. ### 6.4 b022885 (K18) ``` $ git worktree add --detach …/diag-pb2 origin/codex/fix-pilot-backups # b022885 $ bash scripts/test-backup.sh | tail -1 backup tests passed: syntax, CMS round-trip, pg_dump/OpenSSL/scp failures, no plaintext dump on disk test-backup rc=0 $ bash scripts/test-backup-pull.sh | tail -1 backup pull tests passed: CMS+checksum, idempotence, stale/mismatch rejection, path validation, scp failure test-backup-pull rc=0 ``` Новое: ротация копий на ПК в `pull-pilot-backups.sh` (`BACKUP_LOCAL_RETENTION_DAYS`, по умолчанию 90; удаляет только пары с корректным sha256, симлинки и посторонние файлы оставляет; в `test-backup-pull.sh` есть сценарий `local-retention`), `deploy/pilot/krona-staff-backup-user.service` (пользовательский systemd-сервис), `scripts/run-pilot-backup-pull.ps1` (задача Windows), `deploy/pilot/backup.home-server.example`. Ротации на сервере нет (в `docs/BACKUP-RESTORE.md`: «server-side retention policy» — условие включения расписания). В `run-pilot-backup-pull.ps1` и `backup.home-server.example` зашиты личные пути (`C:\Users\trush\…`, `/home/ochenstarik/…`). Systemd-юнит, задача Windows и ротация на реальном диске — не проверено.