Corpus airgap runbook — instance-to-instance corpus transfer¶
Scope: move a corpus between two instances without publishing to chipi/podcast_scraper-backup.
Covers laptop ↔ VPS, prod ↔ codespace, and airgapped restores.
SSOT for corpus snapshot ops: Corpus snapshot manifest and restore.
Related: RFC-084 ·
ADR-092 ·
GitHub #1175.
This runbook covers the hand-carried path only. For the scheduled backup / restore flow that
publishes to the backup repo, use backup-corpus.yml / backup-corpus-prod.yml /
prod-restore-corpus.yml and their two dedicated runbooks
(Prod runbook, DR drill runbook).
When to use this path¶
| Situation | Use this runbook | Use the CI path instead |
|---|---|---|
| One-off transfer between two instances | ✅ | |
Airgap: target has no gh / no GitHub access |
✅ | |
| Corpus surgery on a laptop, need to ship result to prod | ✅ | |
| Scheduled daily / weekly backups | ✅ (backup-corpus[-prod].yml) |
|
| Prod → drill for a DR exercise | ✅ (drill-restore-corpus.yml) |
|
| Publishing an artifact to the backup repo | ✅ |
Pre-flight checklist (source instance)¶
- [ ] Corpus root identified (the parent that contains
feeds.spec.yaml— usually.codespace_corpus/or/srv/podcast-scraper/corpus/). - [ ] At least one
*.gi.jsonexists under the tree (find <corpus_root> -name '*.gi.json' | head). The pack step refuses an empty corpus (mirrorsbackup-corpus.ymlsanity check; see #699). - [ ] Free space at
OUT's parent is at least 2× corpus size (raw copy staging + tarball). - [ ]
git,tar,jqavailable (all standard on macOS/Linux). - [ ] Producer identity: either
GITHUB_SHAis set (CI environments), orgit rev-parse HEADresolves at the repo root (default fallback), orGIT_SHAis set explicitly.
Pre-flight checklist (target instance)¶
- [ ] Target workspace parent chosen. Do not point at a directory that already contains
.codespace_corpus/orcorpus/— the import refuses to overwrite live data. - [ ] Reader-range config
config/corpus_snapshot_reader_support.jsonmatches the code deployed on the target host (the import checkscorpus_format_versionagainst this range). - [ ]
tar,jq, and eithersha256sum(Linux) orshasum(macOS) available.
Recipe: laptop → VPS¶
Source: laptop with the corpus at ~/work/corpus/. Target: prod VPS.
# 1. On the laptop — pack.
make export-corpus \
CORPUS_DIR=$HOME/work/corpus \
OUT=/tmp/corpus-$(date -u +%Y%m%d).tgz \
LAYOUT=prod
# 2. Transport. Ship the tarball AND the sibling manifest together; the sibling
# carries archive.sha256 which import uses for integrity.
scp /tmp/corpus-*.tgz /tmp/snapshot.manifest.json \
deploy@prod-vps.example:/srv/staging/
# 3. On the VPS — import (as the deploy user).
ssh deploy@prod-vps.example
make -C /opt/podcast-scraper import-corpus \
FILE=/srv/staging/corpus-YYYYMMDD.tgz \
WORKSPACE_DIR=/srv/podcast-scraper \
LAYOUT=prod
# 4. Sanity check + container recycle (mirrors prod-restore-corpus.yml host step).
make -C /opt/podcast-scraper corpus-compat-check \
CORPUS_DIR=/srv/podcast-scraper/corpus
docker compose -f /opt/podcast-scraper/compose/docker-compose.prod.yml \
up -d --force-recreate api viewer
Recipe: prod VPS → codespace¶
Source: prod VPS. Target: local codespace (dev laptop).
# 1. On the VPS — pack with codespace layout.
sudo make -C /opt/podcast-scraper export-corpus \
CORPUS_DIR=/srv/podcast-scraper/corpus \
OUT=/tmp/prod-snapshot.tgz \
LAYOUT=codespace
# 2. Pull down. Retrieve both files.
scp deploy@prod-vps.example:/tmp/prod-snapshot.tgz \
deploy@prod-vps.example:/tmp/snapshot.manifest.json \
./
# 3. Import into the codespace bind-mount path.
make import-corpus \
FILE=$PWD/prod-snapshot.tgz \
WORKSPACE_DIR=$PWD \
LAYOUT=codespace
# result: $PWD/.codespace_corpus/
Recipe: airgap sneakernet (USB)¶
Source has no network. Target has no network. Transfer is a USB stick.
# 1. Source: pack.
make export-corpus \
CORPUS_DIR=/data/corpus \
OUT=/media/usb/snapshot.tgz \
LAYOUT=prod
# result on the USB stick: snapshot.tgz + snapshot.manifest.json
# 2. Physically move the USB stick.
# 3. Target: import (offline; no `gh`, no network).
make import-corpus \
FILE=/media/usb/snapshot.tgz \
WORKSPACE_DIR=/srv/podcast-scraper \
LAYOUT=prod
Post-import: apply pending upgrade migrations¶
Importing a snapshot from an older source into a target running newer code will
land a corpus that needs one or more registered migrations (m0002_two_tier_native_reindex,
m0003_gi_v3_typed_mentions, and any future ones — see
Corpus upgrade framework (#862)). The import step does
not run these for you. Run the upgrade path immediately after import so the
next reader / API call is served from a fully-migrated corpus:
CORPUS_DIR=<workspace>/<layout-root>
# Check what will run. Exits 2 when migrations are pending.
make upgrade-check CORPUS_DIR=$CORPUS_DIR
# Apply. Non-interactive; idempotent (already-recorded migrations skip).
make upgrade-corpus CORPUS_DIR=$CORPUS_DIR
# Confirm the ledger.
make upgrade-verify CORPUS_DIR=$CORPUS_DIR
On a prod VPS with the API stack running, do this BEFORE re-creating the api
container (the container needs to boot on the migrated corpus). The
prod-restore-corpus.yml GHA restore path runs this automatically via
scripts/ops/restore_corpus_from_tarball_host.sh (#1176); local airgap imports
need this step by hand.
Verification¶
After every import:
# Manifest + reader-range are checked automatically by import.
# Additionally, run the corpus/code compat check to catch a mismatched deploy:
make corpus-compat-check CORPUS_DIR=<workspace>/<layout-root>
# Count what landed vs what the source reported.
find <workspace>/<layout-root>/transcripts -name '*.gi.json' | wc -l
Compare the count against what find <corpus_root>/transcripts -name '*.gi.json' | wc -l produced
on the source. They must match exactly — a mismatch means a partial transport (truncated scp,
corrupt USB) that slipped past the archive.sha256 check somehow (should be impossible; investigate).
Failure modes¶
| Symptom | Cause | Fix |
|---|---|---|
feeds.spec.yaml missing on pack |
You pointed CORPUS_DIR at a subdirectory instead of the corpus root |
Point at the parent containing feeds.spec.yaml. |
no *.gi.json artifacts on pack |
Corpus was never enriched, or wrong directory | Verify the source path; if truly empty, resolve at the source before packing. |
sha256 mismatch on import |
Sibling manifest was tampered, OR the tarball was truncated in transit | Re-copy the tarball. If persistent, re-pack from source. Only set CORPUS_SNAPSHOT_SKIP_SHA256_VERIFY=1 when you trust the transport (e.g. same-host cp). |
corpus_format_version outside reader range |
Producer version is newer than the target code understands | Upgrade the target code (git pull then rebuild), OR pack from an older source that matches the target's reader range. |
archive top-level does not contain 'X/' on import |
Layout mismatch — you packed with LAYOUT=codespace and are importing with LAYOUT=prod (or vice versa) |
Retry the import with the correct layout. The tarball is unchanged; only the wrapper needs the matching flag. |
<workspace>/<layout-root> already exists on import |
Target already has a corpus | Move/rename the prior one first (mv corpus corpus.pre-import-$(date -u +%Y%m%d%H%M)) to preserve rollback. |
| Pack produced a tarball but no sibling manifest | finalize_backup_bundle.sh failed after tar; check its stderr |
Delete the partial tarball and retry pack; finalize needs jq + tar + a sha256 tool. |
Rollback¶
Before every import that overwrites a live corpus, keep the prior tree:
mv <workspace>/<layout-root> <workspace>/<layout-root>.pre-import-$(date -u +%Y%m%d%H%M)
If the import lands and later reveals a problem:
# Stop containers first (prod).
docker compose -f compose/docker-compose.prod.yml stop api viewer
mv <workspace>/<layout-root> <workspace>/<layout-root>.reverted
mv <workspace>/<layout-root>.pre-import-<ts> <workspace>/<layout-root>
docker compose -f compose/docker-compose.prod.yml up -d --force-recreate api viewer
Rollback should complete in under 5 minutes because the prior corpus stays on disk until you explicitly remove it.
Contract with the CI backup path¶
The tarball produced by make export-corpus is bit-format identical to what
backup-corpus.yml / backup-corpus-prod.yml produce:
- Same layout root (
.codespace_corpus/orcorpus/). - Same inner + sibling
snapshot.manifest.json(viafinalize_backup_bundle.sh). - Same
archive.sha256. - Same
schema_version+corpus_format_versionfields (viaemit_manifest.sh).
Consequences:
- A locally exported tarball can be uploaded to a
chipi/podcast_scraper-backuprelease manually (gh release create ...) and picked up bymake restore-corpuson any other host. - A CI-produced
snapshot.tgzdownloaded from a backup repo release is directly consumable bymake import-corpuswithout anyghcalls — useful when the restore host has no backup-repo credentials.
See also¶
- Corpus snapshot manifest and restore — SSOT for every corpus snapshot surface (this runbook is the airgap slice).
- Prod runbook — prod SSH, secrets, container recycle steps.
- DR drill runbook — drill-only confirms and destroy rules.
- RFC-084 — manifest spec.
- ADR-092 — version-aware selection.