Corpus snapshot manifest and restore (all surfaces)¶
Normative design: RFC-084 · Decision record: ADR-092 · Issue: GitHub #763
This page is the single operator map: how the same backup/restore contract is exercised locally via Make versus remotely via GitHub Actions on production, pre-prod (Codespaces / pre-prod paths), and DR drill. Detailed environment secrets, SSH users, and typed confirms stay in Prod runbook and DR drill runbook.
Principles¶
- One behavior — emit
snapshot.manifest.json, validate, and newest-compatible selection are implemented inscripts/ops/corpus_snapshot/; Make and workflows call those entry points (RFC §5). - Local loop — use
makeon a laptop (withghauth to the backup repo where needed) to validate manifests, rehearse tag selection, and runrestore-corpuswithout opening prod or drill workflows. - Remote execution — GitHub Actions runs the same paths on the right project/host: prod restore/backup workflows for always-on VPS, drill restore for the drill stack, pre-prod/codespace backup via the pre-prod workflow family.
Compatibility config: config/corpus_snapshot_reader_support.json (reader min/max) and
config/corpus_snapshot_format.json (producer corpus_format_version on new backups). Bump both
when on-disk corpus layout changes in a breaking way (ADR-092).
Surface matrix¶
| Surface | Typical goal | Entry (target) | Detail |
|---|---|---|---|
| Local dev | Validate manifest JSON; test selection; download restore without GHA | make corpus-snapshot-manifest-validate, make corpus-snapshot-select-tag, make restore-corpus |
Codespace layout (.codespace_corpus/). Requires network + gh auth for private backup repo when pulling releases. |
| Pre-prod / Codespace | Cloud backup tarball to backup repo; optional local pull | backup-corpus.yml; make codespace-backup-cloud; make restore-corpus |
Codespace tags: snapshot-YYYYMMDD. |
| Production VPS | Corpus snapshot; controlled restore | backup-corpus-prod.yml, prod-restore-corpus.yml; on-host rehearsal: make restore-corpus-prod |
Prod tags: snapshot-prod-YYYYMMDD; tarball top-level corpus/. |
| DR drill | Restore drill host from backup repo | drill-restore-corpus.yml |
Uses snapshot-prod-* selection like prod. |
| Instance-to-instance (local) | Move a corpus laptop ↔ VPS, prod ↔ codespace, or across an airgap without going through the backup repo | make export-corpus + make import-corpus (#1175) |
Same tarball format as backup-corpus.yml; no gh dependency; operator owns the transport (scp / USB / etc.). See Instance-to-instance transfer below and the Corpus airgap runbook. |
GitHub projects: Workflows live on chipi/podcast_scraper. Published assets go to
chipi/podcast_scraper-backup (or PODCAST_BACKUP_REPO) unless overridden.
Local testing (Make-first)¶
- Validate —
make corpus-snapshot-manifest-validate FILE=path/to/snapshot.manifest.json - Select —
make corpus-snapshot-select-tagormake corpus-snapshot-select-tag-prod(optionalTAG_REGEX=…,PODCAST_BACKUP_TAG=…,PODCAST_BACKUP_REPO=…); exits non-zero when no compatible release has a sibling manifest. - Restore —
make restore-corpus(codespace layout; unsetPODCAST_BACKUP_TAG→ newest compatiblesnapshot-YYYYMMDD). On a prod VPS or local prod rehearsal:make restore-corpus-prod(snapshot-prod-*, top-levelcorpus/). Pin withPODCAST_BACKUP_TAG=…. See VPS restore: Make vs GitHub Actions. - Selftest —
make corpus-snapshot-selftest(fixture validation + finalize smoke; nogh).
Verify prod asset without restore: scripts/ops/verify_prod_backup_snapshot.sh (manifest +
tarball inspection).
GitHub Actions (remote hosts)¶
| Workflow | Surface | Role |
|---|---|---|
backup-corpus.yml |
Pre-prod / Codespace | Tarball + finalize_backup_bundle.sh → upload snapshot.tgz + snapshot.manifest.json when dry_run is false. |
backup-corpus-prod.yml |
Prod VPS | Same for /srv/podcast-scraper/corpus (default dry_run=true until operator sets false). |
prod-restore-corpus.yml |
Prod | resolve_latest_snapshot_prod_tag.sh then download_and_verify_snapshot.sh; host extract: restore_corpus_from_tarball_host.sh. |
drill-restore-corpus.yml |
DR drill | Same runner download/verify path; same host restore script on drill deploy@. |
See WORKFLOWS.md and the two runbooks for confirms and secrets.
VPS restore: Make vs GitHub Actions¶
Both paths share scripts/ops/corpus_snapshot/ for tag selection and tarball verification
(ADR-092). They
diverge after extract on the VPS:
| Path | When | After extract |
|---|---|---|
make restore-corpus-prod |
On-host rehearsal, migration SSH, laptop prod-layout rehearsal | Downloads and verifies assets, extracts top-level corpus/ under WORKSPACE_DIR (default /srv/podcast-scraper). Does not recreate containers — recycle api + viewer per Prod runbook corpus migration. |
prod-restore-corpus.yml / drill-restore-corpus.yml |
Controlled GHA restore (typed confirm on manual runs) | Runner uploads tarball + restore_corpus_from_tarball_host.sh; host script does an in-place, inode-preserving swap (swap_corpus_in_place.sh — empties corpus/ and extracts INTO it, keeping the dir inode so the shared bind volume re-resolves; the prior corpus/ is moved to corpus.bak.<ts>, pruned to RESTORE_BACKUP_KEEP), then compose up -d --force-recreate for ALL consumers (not just api+viewer — every stack cached the old corpus at boot; DR-2), then in-container /api/health on api :8000. |
Codespace layout restore stays on make restore-corpus (.codespace_corpus/ in tarball).
Instance-to-instance transfer¶
For moving a corpus between two instances without publishing to chipi/podcast_scraper-backup
(#1175): use make export-corpus on the source and make import-corpus on the target. The
resulting tarball is bit-format identical to what backup-corpus.yml produces, so it is
consumable by the CI restore path — and vice versa: a CI-produced snapshot.tgz sitting on
local disk works with make import-corpus without any gh calls.
Export (source instance)¶
make export-corpus \
CORPUS_DIR=/path/to/corpus_root \
OUT=/tmp/snapshot.tgz \
[LAYOUT=codespace|prod] # default: codespace
CORPUS_DIRmust be the corpus root (the parent that containsfeeds.spec.yaml).LAYOUT=codespace→ archive root is.codespace_corpus/.LAYOUT=prod→corpus/.- Producer identity (
git_sha) falls back togit rev-parse HEADwhen neitherGITHUB_SHAnorGIT_SHAis set in the environment. - Sanity checks refuse to pack: missing
feeds.spec.yaml, no*.gi.jsonunder the tree, or a suspiciously small tarball (< 1 KiB — override viaCORPUS_SNAPSHOT_MIN_TARBALL_BYTESfor tests only). - Output:
OUT(the tarball) plus a siblingsnapshot.manifest.jsonnext to it, carryingarchive.sha256.
Import (target instance)¶
make import-corpus \
FILE=/tmp/snapshot.tgz \
WORKSPACE_DIR=/path/to/target_parent \
[LAYOUT=codespace|prod] # default: codespace
- Prefers the sibling
snapshot.manifest.json(hasarchive.sha256); falls back to the inner one at archive root when the sibling is missing. - Runs the same
validate_snapshot_manifest.sh+ reader-range check (config/corpus_snapshot_reader_support.json) that the CI restore path runs aftergh release download. - Verifies
archive.sha256when the sibling manifest carries one. Skip withCORPUS_SNAPSHOT_SKIP_SHA256_VERIFY=1(only when you already trust the transport). - Refuses to overwrite an existing
<workspace>/.codespace_corpus/or<workspace>/corpus/— move or remove the prior tree first to avoid clobbering live data. - No
ghdependency. Works on a fully offline host as long as the tarball is on disk.
Non-goals¶
- The pair does not add a network transport. Moving the tarball between hosts (scp, S3 CLI, USB) is the operator's choice; see the runbook for worked recipes.
- It does not replace the CI backup path.
backup-corpus.yml/backup-corpus-prod.ymlstay authoritative for scheduled snapshots tochipi/podcast_scraper-backup.
Detailed operator recipes live in the Corpus airgap runbook.
Pre-deploy completeness gate (#1494 / #1497)¶
Run make corpus-completeness-check before exporting a snapshot or cutting over a corpus. It
exits 0 (VERDICT: PASS) when all required artifacts are present and non-empty; non-zero on
any gap.
# Local / on the VPS — must exit 0 before cutover
make corpus-completeness-check CORPUS_DIR=<path-to-corpus-root>
When to run:
- Before
make export-corpus/backup-corpus-prod.yml— ensures the snapshot is complete, not a partial-run artifact. - After
make import-corpus/prod-restore-corpus.ymlrestores a corpus — validates the restored tree before restarting the stack. - As part of the
cutover_corpus_inplace.shsequence — the script already includes this step; verify it exits 0 before proceeding tocompose up --force-recreate.
Post-restore topic-clusters check: the completeness gate validates that
search/topic_clusters.json is present and non-empty. If it is missing after a restore (the
#14 smoke failure pattern), rebuild it before restarting the consumer stack:
docker compose exec -T api python -m podcast_scraper.cli topic-clusters \
--output-dir /app/output --threshold 0.75
# Or: cutover_corpus_inplace.sh already runs this step automatically.
When newest-compatible default is wrong¶
Default restore (unset tag) picks the newest backup whose sibling snapshot.manifest.json
reports a corpus_format_version inside config/corpus_snapshot_reader_support.json. That is
not always the right choice:
- Rollback — deploy an older app build but need a corpus from an older compatible release:
pin
PODCAST_BACKUP_TAG/ workflowbackup_tag. - Format bump — after a breaking corpus layout change, older releases may be skipped until manifests exist; if none match, selection fails closed (pin an explicit tag or publish a new backup).
- Mixed-age hosts — reader range in the repo may differ from what is deployed on a host; pin or align config before restore.
Pre-manifest releases (no sibling manifest) are skipped by default selection until a post-merge backup exists.
Related¶
- Stack contract — cross-surface compose, health, and gate audit table (ADR-093).
- Prod runbook — prod SSH, secrets,
prod-restore-corpus.yml(primary restore). - DR drill runbook — drill-only confirms and destroy rules.
- Hosting and infrastructure — prod vs drill topology.