Skip to content

feat(ops): pull the production snapshots off the deploy host - #145

Merged
Fl0p merged 2 commits into
mainfrom
flo-1042-pull-snapshots
Oct 12, 2026
Merged

Fl0p merged 2 commits into
mainfrom
flo-1042-pull-snapshots

Conversation

@Fl0p

@Fl0p Fl0p commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

What this is

The repo half of the off-host snapshot mirror: scripts/pull-snapshots.sh, its
unit tests, the CI step that runs them, and the runbook section. Per the ticket's
scope boundary, nothing here installs a timer and nothing here writes into
~/ops
- the schedule stays with its own ticket.

The transport is the one ADR-0024 measured: a throwaway --rm container with
cotel-snapshots mounted :ro, tar -cf - over the existing ssh path. A
snapshot directory is immutable once complete, so the sync rule is a set
difference, not a delta.

The bug the real run found

The first production run pulled 1 of 25 snapshots and exited 0. ssh reads
its caller's stdin unless told not to, and the tick's stdin was the list of
instants still to pull - the first pull swallowed the rest of the list and the
script reported success. Fixed with ssh -n plus reading the work list on fd 3,
and the test stub now emulates ssh's stdin consumption so the case is guarded:
reverting either fix turns several new instants in one tick red.

Verification

bash scripts/pull-snapshots_test.sh - green, 81 assertions

== nothing new: a tick over a current mirror writes nothing
PASS: nothing new is exit 0 (exit 0)
PASS: nothing new says it pulled nothing
PASS: nothing new wrote nothing

== one new instant: pulled, verified, landed under its own name
PASS: one new instant is exit 0 (exit 0)
PASS: one new instant is reported
PASS: landed 2026-10-10T06-00-00Z — 5 files sha256-identical to the source
PASS: no leftover partials
PASS: load.sql keeps its absolute /snapshots path, so the landed name must be the instant

== several new instants in one tick: every one of them lands
PASS: four new instants in one tick is exit 0 (exit 0)
PASS: the tick counts all four
PASS: landed 2026-10-10T00-00-00Z — 5 files sha256-identical to the source
PASS: landed 2026-10-10T06-00-00Z — 5 files sha256-identical to the source
PASS: landed 2026-10-10T12-00-00Z — 5 files sha256-identical to the source
PASS: landed 2026-10-10T18-00-00Z — 5 files sha256-identical to the source
PASS: no leftover partials

== a source directory without snapshot.json is never pulled
PASS: half-written source snapshot is skipped, exit 0 (exit 0)
PASS: landed 2026-10-10T00-00-00Z — 5 files sha256-identical to the source
PASS: absent 2026-10-10T06-00-00Z
PASS: no leftover partials

== sha mismatch: exit 3, partial removed, nothing landed
PASS: corrupted transfer is exit 3 (exit 3)
PASS: the mismatching file is named
PASS: verification failure is explicit
PASS: absent 2026-10-10T00-00-00Z
PASS: no leftover partials

== ssh failure: exit 1, mirror untouched
PASS: unreachable source is exit 1 (exit 1)
PASS: unreachable is named as such
PASS: unreachable source left the mirror untouched

== a listing that is not snapshots at all: exit 2
PASS: malformed remote listing is exit 2 (exit 2)
PASS: malformed listing quotes what it got

== an empty source volume: exit 2
PASS: empty remote listing is exit 2 (exit 2)

== the source list shrinks: local copies stay (the mirror does not mirror deletions)
PASS: a source that lost snapshots is still exit 0 (exit 0)
PASS: present 2026-10-10T00-00-00Z
PASS: present 2026-10-10T06-00-00Z
PASS: present 2026-10-10T12-00-00Z
PASS: landed 2026-10-10T18-00-00Z — 5 files sha256-identical to the source

== pruning keeps the newest KEEP and never drops the last one
PASS: prune to 2 is exit 0 (exit 0)
PASS: absent 2026-10-10T00-00-00Z
PASS: absent 2026-10-10T06-00-00Z
PASS: present 2026-10-10T12-00-00Z
PASS: present 2026-10-10T18-00-00Z
PASS: keep 0 is clamped to 1, not to zero snapshots (exit 0)
PASS: absent 2026-10-10T00-00-00Z
PASS: present 2026-10-10T06-00-00Z
PASS: keep 0 left exactly one snapshot standing

== an interrupted previous run: its partial is cleaned up and never read as present
PASS: a tick after an interruption is exit 0 (exit 0)
PASS: the leftover partial is reported
PASS: the manifest-less local copy is reported
PASS: no leftover partials
PASS: landed 2026-10-10T00-00-00Z — 5 files sha256-identical to the source
PASS: landed 2026-10-10T06-00-00Z — 5 files sha256-identical to the source

== an unwritable mirror: exit 4
PASS: unwritable mirror is exit 4 (exit 4)
PASS: unwritable mirror says so

== a mirror whose parent does not exist: exit 4, nothing created
PASS: mirror under a missing parent is exit 4 (exit 4)
PASS: the missing parent is named
PASS: nothing created under the missing parent

== a missing mirror is created on the first tick
PASS: first tick into a missing mirror is exit 0 (exit 0)
PASS: mirror creation is reported
PASS: landed 2026-10-10T00-00-00Z — 5 files sha256-identical to the source
PASS: the mirror is created mode 700 (it carries the same plaintext tokens as the data volume)

== --dry-run touches nothing
PASS: --dry-run is exit 0 (exit 0)
PASS: --dry-run names what it would pull
PASS: --dry-run names what it would prune
PASS: --dry-run restates the deletion rule
PASS: --dry-run wrote nothing

== --dry-run on a current mirror has nothing to do
PASS: --dry-run on a current mirror is exit 0 (exit 0)
PASS: --dry-run says there is nothing to pull
PASS: --dry-run says there is nothing to prune

== --status prints the block a human reads
PASS: --status is exit 0 (exit 0)
PASS: --status names the newest local instant
PASS: --status counts the mirror and its bytes
PASS: --status names the newest source instant
PASS: --status prints the lag
PASS: --status says how much is outstanding
PASS: --status on a current mirror is exit 0 (exit 0)
PASS: --status calls a current mirror current
PASS: --status without a mirror is exit 4 (exit 4)
PASS: --status without a mirror says so

== invocation errors
PASS: an unknown flag is exit 64 (exit 64)
PASS: --help is exit 0 (exit 0)
PASS: --help lists the exit codes
PASS: a non-numeric keep is exit 64 (exit 64)

passed=81 failed=0

No network, no docker, no deploy host: a stub ssh on PATH serves a directory
tree as the volume, and can stream from a tampered copy while hashing the
pristine one, which is how exit 3 is exercised.

A real run from the Pi against production

First run, empty mirror - all 25 snapshots, 26.8 s wall clock, 9 files verified
each:

$ time ./scripts/pull-snapshots.sh
pull-snapshots: created mirror directory /home/flop/cotel-snapshots (mode 700)
pull-snapshots: pulled 2026-10-06T17-39-54Z (9 files verified)
pull-snapshots: pulled 2026-10-06T23-39-54Z (9 files verified)
pull-snapshots: pulled 2026-10-07T05-39-54Z (9 files verified)
pull-snapshots: pulled 2026-10-07T11-39-54Z (9 files verified)
pull-snapshots: pulled 2026-10-07T17-39-54Z (9 files verified)
pull-snapshots: pulled 2026-10-07T23-39-54Z (9 files verified)
pull-snapshots: pulled 2026-10-08T05-39-54Z (9 files verified)
pull-snapshots: pulled 2026-10-08T11-39-54Z (9 files verified)
pull-snapshots: pulled 2026-10-08T17-39-54Z (9 files verified)
pull-snapshots: pulled 2026-10-08T23-39-54Z (9 files verified)
pull-snapshots: pulled 2026-10-09T05-39-54Z (9 files verified)
pull-snapshots: pulled 2026-10-09T11-39-54Z (9 files verified)
pull-snapshots: pulled 2026-10-09T17-39-54Z (9 files verified)
pull-snapshots: pulled 2026-10-09T23-39-54Z (9 files verified)
pull-snapshots: pulled 2026-10-10T05-39-54Z (9 files verified)
pull-snapshots: pulled 2026-10-10T11-39-54Z (9 files verified)
pull-snapshots: pulled 2026-10-10T17-39-54Z (9 files verified)
pull-snapshots: pulled 2026-10-10T23-39-54Z (9 files verified)
pull-snapshots: pulled 2026-10-11T05-39-54Z (9 files verified)
pull-snapshots: pulled 2026-10-11T11-39-54Z (9 files verified)
pull-snapshots: pulled 2026-10-11T17-39-54Z (9 files verified)
pull-snapshots: pulled 2026-10-11T23-39-54Z (9 files verified)
pull-snapshots: pulled 2026-10-12T05-39-54Z (9 files verified)
pull-snapshots: pulled 2026-10-12T11-39-54Z (9 files verified)
pull-snapshots: pulled 2026-10-12T17-39-54Z (9 files verified)
pull-snapshots: OK — pulled 25, already here 0; mirror holds 25 complete snapshots, newest 2026-10-12T17-39-54Z in /home/flop/cotel-snapshots

real	0m26.803s

Second run - nothing to do, one ssh round trip, exit 0:

$ time ./scripts/pull-snapshots.sh
pull-snapshots: OK — pulled 0, already here 25; mirror holds 25 complete snapshots, newest 2026-10-12T17-39-54Z in /home/flop/cotel-snapshots

real	0m0.511s

--status:

$ ./scripts/pull-snapshots.sh --status
pull-snapshots: status
  mirror       /home/flop/cotel-snapshots — 25 complete snapshots, 118M on disk, keep 56
  local newest 2026-10-12T17-39-54Z (age 1h37m)
  source       robmini:cotel-snapshots — newest 2026-10-12T17-39-54Z (age 1h37m), 25 complete
  lag          0s — the mirror holds the newest source snapshot
  to pull      0 source snapshot(s) not here yet

--dry-run on a mirror that is already current:

$ ./scripts/pull-snapshots.sh --dry-run
pull-snapshots: dry run — nothing will be written
  mirror       /home/flop/cotel-snapshots — 25 complete snapshots, keep 56
  would pull   nothing — every complete source snapshot is already here
  would prune  nothing — 25 complete snapshots against keep 56
  would delete nothing because the source stopped having it (the mirror does not mirror deletions)

118 MB on disk, mode 700, 303 GB free on the Pi. The mirror is left in place.

Docs

cd docs && npm run build - VitePress build complete, no dead links.

Two things to look at

  • Exit 64 is an addition to the ticket's five codes, for an unknown flag or
    a non-numeric SNAPSHOT_MIRROR_KEEP/SNAPSHOT_SSH_TIMEOUT. 0-4 keep
    exactly the meanings the ticket assigned, and a config error is not a tick
    outcome, so folding it into 2 or 4 would make the alert lie about what
    happened. Say the word and it collapses into one of the five.
  • The Host loss bullet is a conflict resolution. docs(decisions): ADR-0024 - the off-host snapshot copy is a Pi-side pull #143 rewrote that exact
    bullet while this branch was open; rebased onto it, the bullet now keeps your
    ADR-0024 link and adds the pointer to the new section, with a line saying a
    mirror is only as current as its last tick.
  • Env knob names follow the ticket (SNAPSHOT_MIRROR_KEEP), not the ADR's prose
    (COTEL_SNAPSHOT_MIRROR_KEEP) - the COTEL_ prefix in this repo means "read
    by the binary", and none of these are.

Not in scope, by the ticket

No ~/ops wrapper, no systemd unit, no timer. Nothing is scheduled: the
mirror is current as of this run and will go stale until the schedule half
lands.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 31 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 9569f56e-65e4-4acc-930b-1187f2007c76

📥 Commits

Reviewing files that changed from the base of the PR and between ead35e6 and af009da.


📒 Files selected for processing (5)
  • .github/workflows/health-probe.yml
  • README.md
  • docs/operations/duckdb-snapshots.md
  • scripts/pull-snapshots.sh
  • scripts/pull-snapshots_test.sh

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

A Pi-side pull, because a Docker named volume on macOS has no host path for
rsync to read: the bytes come out of a throwaway read-only container over the
existing ssh path. A snapshot directory is immutable once complete, so the sync
rule is a set difference and not a delta - pull the complete source instants
that are not here, skip the ones that are.

Every pulled directory is verified before it lands: per-file sha256 against the
same bytes read back out of the source volume, plus snapshot.json parsing and
agreeing with the instant it was pulled under. Extraction goes into
.partial-<instant> and is renamed only after that passes, so an interrupted
pull leaves nothing a later tick reads as present.

The mirror does not mirror deletions. It prunes on its own count, never below
one, and never deletes a local snapshot because the source stopped having it -
propagating a source deletion would reproduce `docker volume prune` on a
stopped deploy into the copy that exists to survive it. A test holds the rule.

--status answers "is the mirror current" without trusting that a timer ran,
--dry-run writes nothing, and the exit codes split source-unreachable from
bad-listing from failed-verification from unusable-mirror so the schedule's
alert says which one happened.

Co-Authored-By: Wayland <wayland@agents.flopbut.local>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Fl0p
Fl0p force-pushed the flo-1042-pull-snapshots branch from f1687ee to ea13b42 Compare October 12, 2026 19:23
The comment said -n was load-bearing because the tick's stdin is the work
list. It is not: the loop reads the list on fd 3, so either guard alone is
sufficient. Reverting one keeps the suite green and only reverting both
reproduces the silent single-pull success, which is what the comment is
there to prevent.

Co-Authored-By: Daedalus <daedalus@agents.flopbut.local>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Fl0p
Fl0p merged commit 260d084 into main Oct 12, 2026
8 checks passed
@Fl0p
Fl0p deleted the flo-1042-pull-snapshots branch October 12, 2026 19:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant