验证(08-23-isolated-backup-restore-drill): 固化隔离恢复runner合同

这个提交包含在:
2026-08-25 12:00:33 +08:00
父节点 41da4c2638
当前提交 5ec7db13b2
共修改 12 个文件,包含 2334 行新增和 0 行删除
+250
查看文件
@@ -1210,6 +1210,256 @@ test "$(printf '%s\n' "$actual" | LC_ALL=C sort)" = \
"$(printf '%s\n' "$expected" | LC_ALL=C sort)"
```
## Scenario: Off-Host Archive And Private-Copy Identity Validation
### 1. Scope / Trigger
- Trigger: an off-host recovery drill copies a formally archived backup batch
into a private temporary directory before database or attachment restore.
- Purpose: preserve the archive placement evidence at the source boundary while
validating a renamed private copy by its source-bound content identity.
### 2. Signatures
- Source validation:
`validate_source_batch(<archive-run-directory>) -> source_metadata`.
- Private-copy validation:
`validate_copied_batch(<private-copy-directory>, <source_metadata>)`.
- `source_metadata` contains only redacted comparison values: verified run id,
manifest/build/time identity, exact three-file set, byte sizes and SHA-256
values. It must not contain credentials, business payloads or full paths.
### 3. Contracts
- A formal archive run directory must have a basename that exactly equals the
validated manifest run id. This detects a valid manifest placed under the
wrong archive run and is mandatory before any copy or restore write.
- A private copy may use an operator-owned neutral basename such as `batch`;
that basename is not archive provenance and must not be compared with the
manifest run id.
- The copied manifest run id must exactly equal the run id already verified at
the source. Internal self-consistency inside the copy is insufficient.
- The copy must repeat manifest schema/status, run-id format, exact
`manifest.json`/`database.dump`/`storage.tar.gz` regular-file set, build and
age gates, byte-size checks and SHA-256 checks.
- After repeated validation, every redacted copied metadata field must equal
`source_metadata`. Copy validation must never disable a source-side gate or
accept a newly invented identity.
- Formal archives remain read-only. A validation failure stops before creating
PostgreSQL, extracting attachments or modifying the archive, and cleanup must
prove zero private runtime and privilege-entry residue.
### 4. Validation & Error Matrix
- Source basename differs from manifest run id -> `batch_identity`; `NO_GO`
before copy.
- Copied manifest run id differs from the verified source run id, even when the
copy is internally self-consistent -> `copied_batch_invalid/batch_identity`;
`NO_GO` before restore runtime creation.
- Copied file set, bytes, SHA-256, build, time or other redacted metadata differs
from the source -> `copied_batch_invalid`; remove only the owned private
runtime and retain the archive unchanged.
- Private basename differs from the run id but all source-bound content checks
pass -> valid; continue to the separately approved restore step.
- Any cleanup residue remains -> attempt stays non-clean and cannot be reported
as recovery `GO`.
### 5. Good/Base/Bad Cases
- Good: source directory basename matches its manifest run id; the same batch
is copied to a private directory named `batch`, revalidated, and its redacted
metadata exactly equals the source record.
- Base: source and copy basenames happen to match; both still use their proper
validation modes and the copy remains bound to `source_metadata`.
- Bad: reuse source validation on the private directory and reject it only
because the operator chose a neutral basename.
- Bad: skip the copy basename check and then trust any internally consistent
replacement manifest without comparing it with the verified source run id.
### 6. Tests Required
- Positive: a formal source whose basename matches its manifest run id passes.
- Positive: an unchanged copy under a different private basename passes and
produces metadata equal to the source record.
- Negative: source basename/run-id mismatch fails before copy.
- Negative: copied content or checksum mutation fails before restore.
- Negative: a copied manifest with an internally consistent but source-different
run id fails, proving the source binding cannot be bypassed.
- Regression: run the existing synthetic backup/restore contract and assert no
PostgreSQL object, extracted attachment, private runtime or privilege entry is
left after every pre-runtime failure.
### 7. Wrong vs Correct
#### Wrong
```text
validate_batch(source, require_basename_match=true)
copy source -> private/batch
validate_batch(private/batch, require_basename_match=true)
```
#### Correct
```text
source_metadata = validate_source_batch(source_run_directory)
copy source_run_directory -> private/batch
validate_copied_batch(private/batch, source_metadata)
```
## Scenario: Shadow-Restore Failure Diagnostics Before Teardown
### 1. Scope / Trigger
- Trigger: a one-shot off-host shadow restore keeps raw tool output only in a
private runtime that must be deleted on every exit path.
- Purpose: preserve enough allowlisted stage evidence to locate a restore
failure without retaining or publishing raw stderr, paths, credentials, SQL
or restored object names.
### 2. Signatures
- Required stages: `dump_list`, `target_confirmation`, `restore_apply`, each
with the enum `PASS|FAIL|NOT_RUN`.
- Required execution fields: `restore_execution_path=DOCKER_STDIN|HOST_FILE|NOT_RUN`,
`dump_list_exit_code`, `restore_script_exit_code`,
`pg_restore_exit_code`, `pg_restore_error_count` and
`restore_error_category`.
- Exit-code fields are non-negative integers or `NOT_RUN`;
`pg_restore_error_count` additionally permits `UNKNOWN` when no safe numeric
summary is available.
- Tracked supply-chain entries:
`scripts/qa/offhost-shadow-restore-runner.template.sh`,
`scripts/qa/validate-offhost-shadow-restore-attachments.py` and
`bash scripts/qa/run-offhost-shadow-restore-runner-contract.sh`.
- Candidate render:
`bash scripts/qa/run-offhost-shadow-restore-runner-contract.sh render <private-config.json> <new-output>`;
it prints only PASS and the candidate SHA-256, never config values or paths.
### 3. Contracts
- Emit the fixed diagnostic fields on the failure path before runtime teardown.
Teardown remains in a trap/finally-equivalent path and must still run after
diagnostic emission; callers retain stdout, not the private raw log.
- An ARM restore runner fixes `restore_execution_path=DOCKER_STDIN`. A private
Docker proxy may observe the real `pg_restore --list` and apply calls and
persist only their stage name and numeric exit code; it must not record argv,
environment values or stderr.
- Run `dump_list` and target confirmation independently before the existing
restore script. A failed stage leaves every later stage `NOT_RUN`.
- Raw restore output remains mode `0600`, never leaves the off-host node and is
deleted with the owned runtime on success and failure.
- Any non-zero pg_restore apply exit is `FAIL/NO_GO`, even when a numeric
"errors ignored" count can be extracted. There is no partial-success
allowlist. Relaxing that rule requires a separately approved design proving
the exact restored object set.
- A diagnostic failure is an exercise-runner finding until evidence identifies
a dump or restore-content defect. It must not be summarized as proof that the
latest backup is unrecoverable.
- The tracked runner is a template, not a site-specific script. Remote address,
account, paths, container/database names, timer units, build identity and
migration fingerprint arrive only through a private JSON with an exact key
set. Rendering validates every value's type, charset and safety shape before
exclusively creating a candidate; runtime environment variables cannot
override those rendered constants.
- The exact rendered candidate must pass the tracked contract and preserve a
deterministic SHA-256 before upload. A workspace-only runner, hand-edited
candidate or contract failure is a hard upload blocker.
- Private render JSON and site-specific candidates must live outside the
repository or under the dedicated ignored `/.restore-drill-private/`
directory. The renderer checks both input and output with Git ignore rules
and fails before candidate creation when an in-repository path is not
ignored; operator memory is not a control.
- The mode-`0600` raw log is truncated exactly once at initialization. Container
start, dump list, target confirmation, restore apply and storage restore each
append a fixed stage marker and their private output, so a later failure does
not erase earlier-stage diagnostics before final teardown.
### 4. Validation & Error Matrix
- Dump list non-zero -> `dump_list=FAIL`, later stages `NOT_RUN`, category
`DUMP_LIST_NONZERO` / `dump_list_failed`.
- Target query non-zero or exact shadow name mismatch ->
`target_confirmation=FAIL`, apply `NOT_RUN`, category
`TARGET_CONFIRMATION_FAILED`.
- Independent list and target pass, but the restore script never invokes apply
-> category `APPLY_EXIT_CODE_MISSING`; fail closed.
- Apply exit non-zero -> `restore_apply=FAIL`, preserve the exact numeric exit
and safe numeric/`UNKNOWN` error count, category `RESTORE_APPLY_NONZERO`.
- Apply and script exit zero -> `restore_apply=PASS`; later migration, table and
attachment checks still decide the final recovery result.
- Diagnostics emitted only after cleanup, or cleanup residue non-zero -> the
attempt is non-clean and cannot be reported as `GO`.
- Missing/extra render key, newline/shell syntax, unsafe path/name, malformed
digest or pre-existing output -> render fails without publishing a partial
candidate.
- In-repository private config or candidate path not covered by Git ignore ->
render fails before the candidate exists. The dedicated ignored directory
remains valid for a locally protected render.
- Any raw-log truncating redirect after initialization, or a missing fixed stage
marker -> tracked contract failure; do not publish a candidate.
- Candidate differs from the deterministic render or has not passed the tracked
contract -> do not upload or install it.
### 5. Good/Base/Bad Cases
- Good: dump list and target pass, apply exits `23`; fixed fields show
`DOCKER_STDIN`, `23` and `RESTORE_APPLY_NONZERO` before teardown, then cleanup
reports zero residue without exposing the raw error.
- Base: all three stages pass with exit code `0`; the runner continues to
migration and attachment verification.
- Bad: emit only `database_restore_failed` after deleting the sole private log.
- Bad: parse and publish arbitrary pg_restore lines, or treat a non-zero exit as
success because the restored database appears partly populated.
- Bad: commit one host's concrete runner or reconstruct a supposedly equivalent
runner from chat/workspace immediately before an exercise.
### 6. Tests Required
- Directly drive the repository `scripts/restore-db.sh` with a real custom dump
through Docker stdin and host-file execution paths; do not replace the script
with an equivalent test implementation.
- Inject list failure, target mismatch and list-pass/apply-failure. Assert exact
stage enums, later-stage `NOT_RUN`, execution path and numeric exit code.
- Inject a non-zero apply exit and assert the diagnostic is emitted before
teardown, teardown passes, and the calling test can still read the fixed
fields afterwards.
- Scan captured diagnostics for fixture credentials, connection strings, SQL,
temporary paths and free-form tool output.
- Run the existing synthetic paired backup/restore and automatic off-host
contract suites after changing the diagnostic runner.
- Render the same private fixture twice and require byte equality; inject shell
syntax and require failure before the output file exists.
- Assert an unignored in-repository private config and an unignored candidate
path both fail before output creation; assert the dedicated ignored directory
accepts synthetic input and output without becoming trackable.
- Statically require exactly one raw-log truncation at initialization and fixed
append markers for container start, dump list, target confirmation, restore
apply and storage restore.
- Directly test source/copy identity, dynamic migration fingerprint and the
tracked attachment validator. Run `check-sensitive-content.mjs` explicitly on
all three tracked runner assets and require zero findings.
### 7. Wrong vs Correct
#### Wrong
```text
restore-db > private.log || fail database_restore_failed
finally: delete private runtime
print result=NO_GO
```
#### Correct
```text
tracked template + exact private config -> deterministic candidate + SHA-256
contract PASS -> list -> target confirmation -> real restore path
capture only stage/path/exit/category enums
emit fixed diagnostics -> finally teardown raw private runtime
non-zero apply remains FAIL
```
## Scenario: Daily Restricted Off-Host Backup
### 1. Scope / Trigger