List remote snapshots without requiring the private key (closes #64) #83
Reference in New Issue
Block a user
Delete Branch "fix-snapshot-list-remote"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Fixes #64.
ListSnapshotsbuilt its table entirely from the local SQLite index.The only remote access,
reportRemoteDrift, was gated onAgeSecretKey != ""— so on a correctly configured host, which bydesign holds no private key,
snapshot listnever contacted thedestination store at all. A user who lost their local index could not
see their own backups, and the
<remote only>cell the READMEdocuments was unreachable dead code.
Review rework (head
9a45221)The first review returned FAIL with three blocking findings. All three
are fixed in
9a45221, each with a test that fails without the fix.No JSON shape change, so nothing here breaks a machine consumer.
1. The TIMESTAMP column mixed two timezones. Remote-only rows
rendered
timestamp.UTC(); local rows came fromListRecent, whosescanner (
internal/database/snapshots.go:767) decoded Unix seconds inthe host's local zone — unlike its two siblings at lines 200 and 639,
which decode in UTC. Both render through the same zone-less format
string, so on a non-UTC host the same snapshot showed one time when
locally tracked and a different time when remote-only, in the same
column, with nothing to indicate why.
Fixed at the point the timestamp enters the domain, not at the display
layer:
scanSnapshotRowsnow decodes in UTC like its siblings, andGetIncompleteByHostname's inline copy of that loop was folded onto theshared scanner, so the call sites cannot drift apart again. (That fold
was also required by
dupl, which correctly started firing once the twoloops became token-identical.)
Covered by
TestSnapshotTimestampsDecodeAsUTC(
internal/database/snapshots_test.go), which checks every snapshotreader and compares
*time.Locationpointers, so it is equally stricton a UTC host —
time.Unixreturnstime.Local, which is never thesame
Locationvalue astime.UTC; and byTestListSnapshots_TimestampsAreUTCOnNonUTCHost, which pinstime.Localto+07:13and asserts a local row and a remote-only rowbuilt from the same instant render the same wall clock in the table and
the same string in
--json. Without the fix the local row renders2026-03-01 17:13:00against the remote row's2026-03-01 10:00:00.2.
--jsonsilently truncated. The early return skippedreportListDrift, so neither themaxRemoteOnlyRows= 1000 cap nor theunreadable-manifest count reached a machine consumer: past 1000
remote-only snapshots the document was short with no signal at all.
Both counts now go to
v.Stderr— the same stream theunreachable-destination warning already uses — via
reportJSONListingLimits. The document's shape is deliberatelyunchanged, so existing consumers keep parsing; a consumer that must
react to truncation can treat any output on stderr as "this listing is
not the whole picture".
Covered by
TestListSnapshots_JSONReportsTruncation(1001 remote-onlysnapshots, asserts 1000 rows plus the truncation notice) and
TestListSnapshots_JSONReportsUnreadableManifests.3. The
--jsonstderr workaround was half-applied. Twoper-snapshot
log.Warncalls on the same new path were left unguarded,and
internal/logbuilds its logger overos.Stdoutat default levelWarn, so one corrupt manifest or one bad manifest timestamp put a JSONlog line on stdout ahead of the document and broke
| jq.Both now route through
warnWhileListing, which picksv.Stderrin--jsonmode andlog.Warnotherwise. They are also collected duringthe concurrent manifest reads and emitted afterwards in key order, since
v.Stderris not safe for concurrent writes — that also makes warningorder deterministic. The workaround stays local and still carries the
comment saying to remove it when #82 lands. #82 itself is untouched.
Covered by
TestListSnapshots_JSONStdoutIsOnlyTheDocument, whichredirects the process's own
os.Stdoutto a pipe and rebuilds thelogger over it, then points the JSON encoder and the UI at the same
pipe. That is what
snapshot list --json | jqactually sees, and it isthe only way a test can observe the defect —
log.Initializebinds toos.Stdout, not to any writer a test can inject. With the fix revertedthe test captures both log lines ahead of the array and the parse fails.
Nits from the review, all three taken: the destination-listing
failure is no longer printed twice in table mode (
UI.Warningfalone;quiet mode still shows warnings, so nothing is lost);
formatRemoteOnlyIDis no longer computed and discarded for locally tracked rows; and the
merge sort is
sort.SliceStable, so rows sharing the zero-timestampfallback keep a deterministic order as a local property rather than an
incidental one.
Rework verification. The review was right that the earlier
script/cibuildexit code proved nothing — that build resolved fromlayer cache. Forced uncached both ways this time:
GOFLAGS=-count=1 make check→EXIT=0. 14okpackage lines,none marked
(cached); lint printed0 issues.script/cibuildwithBUILDKIT_PROGRESS=plain→EXIT=0, andthe source
COPYinvalidated both stages, so neither wasCACHED:#16 [lint 8/8] RUN make lintran 57.7s and printed0 issues.;#23 [builder 8/9] RUN make testran 69.1s and printed theoklineswith none
(cached)..golangci.ymlstill hashes to021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb.Dockerfile,Makefile,.gitea/andscript/remain byte-identicalto
main. No existing test was weakened or deleted; the only change toan existing test is one added field on the
--jsonrow struct and anaddRemotehelper split so a manifest can carry a raw timestamp string.TODO.md's Next Step remains unrotated, per the review.What changed
The listing is now the union of the local index and the destination
store, with no
age_secret_keygate. The manifest is unencrypted, so ahost holding only the public key can enumerate what it has backed up.
The naming constraint from the manager comment is honored exactly: a
remote-only snapshot's hostname and name are not recovered and
nothing new is written to remote storage.
RemoteSnapshotKeyisone-way and the manifest stores the hash rather than the human ID, so
those live only in the local index and the encrypted
db.zst.age.Remote-only rows are labelled
<remote only:<12 hex chars>>, carry thereal
timestampandtotal_compressed_sizefrom the manifest, andshow
<remote only>in the two columns that genuinely require thelocal index.
Most of the listing code moved from the 1857-line
snapshot.gointo anew
internal/vaultik/snapshot_list.go, so the whole feature reads inone place.
Verification
See the rework verification above for the current head. The original
end-to-end run against a
file://destination is unchanged and is keptbelow.
The no-private-key property
Two independent checks.
Unit.
TestListSnapshots_RemoteWithoutSecretKeybuilds aVaultikwith
AgeSecretKey: ""(asserted, so the test cannot silently stoptesting what it claims) against a storer that counts prefix listings
and records every fetched key. It asserts the destination was listed
exactly once, that no key containing
.agewas ever read, that theremote-only row renders, and that the human ID is neither shown nor
fabricated. If the remote listing is ever gated on the secret key
again, this fails.
End to end, with the real binary against a
file://destination, aconfig containing no
age_secret_key, and noVAULTIK_AGE_SECRET_KEYin the environment. Backup, then delete the local index, then list:
With the index intact the same snapshot renders as a normal named row
with real numbers in all three size columns, confirming both branches:
--jsonon the same remote-only state:So requirement 2 is verified, not assumed:
remoteOnlyCelland theLocallyTracked == falsebranch render correctly in both the table andJSON, in tests and against the real binary.
Requirements
6 — the
vaultik snapshot cleanupstring: renamed tovaultik prune. The issue asks whether the command was removed or neveradded. It was removed and the message orphaned:
internal/vaultik/prune.go:80-82callsCleanupLocalSnapshotsand itscomment says so outright — "This used to be the separate 'snapshot
cleanup' command and is now folded in". So
CleanupLocalSnapshotsisnot unreachable; it is
prune's first pass. Re-adding asnapshot cleanupcommand would restore the duplicate entry point that the2026-07-02 CLI consolidation deliberately removed, so the message now
names
vaultik prune, and a test asserts the stringsnapshot cleanupdoes not appear in the output.
9 —
reportRemoteDriftcollapses. Its remote-only half is fullysubsumed by the merged table: those snapshots are rows now, with real
timestamps and sizes, rather than a bare count in a footnote. Its
local-only half is still meaningful but no longer needs a remote
listing of its own — it became
reportListDrift, which reads the mergeListSnapshotsalready computed. Net effect:snapshot listlists thedestination exactly once per invocation, where the old code would have
listed it twice had both halves ever run.
10 — the stale branch needs nothing folded in.
origin/fix/sync-snapshot-cleanup(tip
332ea26) changes exactly one line:v.Repositories.Snapshots.Delete(...)to
v.deleteSnapshotFromLocalDB(...)insyncWithRemote. That changeis already on
mainatinternal/vaultik/snapshot.go:1186, havinglanded independently through the
deleteSnapshotFromLocalDBerror-propagation work (
ddc23f8/597b560). The three-dot diff stillshows it only because the merge base (
c24e7e6) predates both. There isnothing to fold and nothing to collide with; the branch is redundant and
can be deleted under #71.
7 — single manifest reader.
downloadManifestByKeyis now the onlyplace in the codebase that reads a remote manifest.
verify.goandinfo.goeach opened and decodedmetadata/<key>/manifest.json.zstinline; both were routed through thehelper in the first commit, and its doc comment now states the
invariant and why #81 depends on it.
8 — bounding. One streamed listing of the
metadata/prefix, sorequest count does not scale with snapshot count. Manifest reads happen
only for keys the local index does not already account for, capped at
maxRemoteOnlyRows(1000) with the overflow reported as a count — intable mode and now in
--jsontoo — and run through anerrgrouplimited to 8 in flight. Nothing beyond the per-row summaries is
retained.
3 — local-only drift is still reported below the table, and is now
also visible in
--jsonviaremote_present: false.4 — degradation. An unreachable destination is a warning plus
local-only output and a zero exit code.
remote_presentisnullrather than
false, so "absent" and "unknown" stay distinguishable, andno drift is claimed on the basis of a listing that never happened.
5 —
--jsongainsremote_key(full 64-character key) andremote_present, alongside the existinglocally_tracked.One thing to flag
In
--jsonmode every warning on the listing path is written tov.Stderrdirectly rather than throughlogorv.UI. Both of thosewrite to stdout (
internal/log/log.go:73-78), which would corruptthe JSON document. That is a pre-existing bug affecting every
--jsoncommand — an 0644 config file alone is enough to break
snapshot list --json | jqonmaintoday — so it is filed as #82 rather than fixeddrive-by. The workaround here should be removed once #82 lands.
Tests
internal/vaultik/snapshot_list_test.go, all against a config with noage secret key:
TestListSnapshots_RemoteWithoutSecretKey— the regression guarddescribed above.
TestListSnapshots_RemoteOnlyRowRendering— pins the remote-only row:identifier column leads with the abbreviated key, real timestamp and
size, exactly two
<remote only>cells.TestListSnapshots_MergesLocalAndRemote— both sources in one table;the locally tracked row keeps its human ID and is not marked
remote-only.
TestListSnapshots_LocalOnlyReportedAsDrift— drift reported, hintnames
vaultik prune, output does not containsnapshot cleanup.TestListSnapshots_UnreachableRemoteDegrades— warning, local rowsstill printed, nil return (exit 0), and no drift claimed.
TestListSnapshots_UnreadableManifestDoesNotHideOthers— one corruptmanifest cannot suppress the rest.
TestListSnapshots_JSONMergedView— all three states in one document.TestListSnapshots_JSONUnreachableRemote— stdout stays parseable,remote_presentis null, warning on stderr.TestListSnapshots_TimestampsAreUTCOnNonUTCHost— new; blockingfinding 1.
TestListSnapshots_JSONReportsUnreadableManifestsandTestListSnapshots_JSONReportsTruncation— new; blocking finding 2.TestListSnapshots_JSONStdoutIsOnlyTheDocument— new; blockingfinding 3.
And
internal/database/snapshots_test.go:TestSnapshotTimestampsDecodeAsUTC— new; blocking finding 1, at thenormalization point itself.
No existing assertions were weakened or removed.
Docs
README's
snapshot listsection and theListSnapshotsdoc commentboth rewritten to match implemented behavior.
TODO.mdupdated in thesame commit as the work.
Note on
TODO.md: I added a Completed Steps entry but left NextStep as it was ("Triage the stale remote branches, issue #71"),
because that is not the work I did — rotating it would have recorded a
task as done that isn't. This PR does resolve one of #71's branches:
fix/sync-snapshot-cleanupis redundant and can be deleted.Commits
0952925Route every remote manifest read throughdownloadManifestByKey0e2929dList remote snapshots without requiring the private key(closes #64)
9a45221Fix timezone drift and--jsontruncation in snapshot list(closes #64)
Review: PR #83 — FAIL (
needs-rework)Head
0e2929d, basemainaf607e3. Mergeable (base is an ancestor ofhead; no conflicts). CI green on the head commit (
check / check (pull_request), success, 2m13s).The design is right, the privacy constraint is honored exactly, and the
tests are real. Three defects in the newly-merged output path block it.
Gate
script/cibuild→EXIT=0.That result is worthless on its own: every stage resolved from the layer
cache (
#17 [lint 8/8] RUN make lint→CACHED,#18 [builder 8/9] RUN make test→CACHED), so neither the linter nor the test suiteactually executed. I re-ran the checks uncached through the repo
entrypoints (
GOFLAGS=-count=1 make check, which isscript/check→script/testwith-race,script/lintagainst the digest-pinnedimage,
script/fmt-check):okpackage lines, none marked(cached).0 issues.EXIT=0So the gate is genuinely green; the author's claim is true, it just
could not be confirmed from the cached build alone. (The
gomodguard-deprecation warning in the lint output is pre-existing fromthe #61 rollout, not this PR.)
Blocking findings
1. The merged TIMESTAMP column mixes two timezones
internal/vaultik/snapshot_list.go:291— remote-only rows:Timestamp: timestamp.UTC()internal/vaultik/snapshot_list.go:336— local rows:Timestamp: ls.StartedAtinternal/database/snapshots.go:767(scanSnapshotRows, the scannerListRecentuses):snapshot.StartedAt = time.Unix(startedAtUnix, 0)— no
.UTC(), unlike the sibling scanners at lines 200 and 639which do call
.UTC().internal/vaultik/snapshot_list.go:476renders both withsnap.Timestamp.Format("2006-01-02 15:04:05")— no zone suffix.On any host whose
TZis not UTC, locally-tracked rows print local walltime and remote-only rows print UTC, in the same column, with nothing to
distinguish them. A snapshot taken at 12:00 CEST shows as
12:00:00while the identical snapshot, once the local index is gone, shows as
10:00:00. This is precisely the reconciliation the feature exists tosupport, and the table silently lies about it. The
--jsontimestampfield inherits the same split (RFC3339 offset
+02:00for local rows,Zfor remote-only), so string comparison across rows breaks too.Neither the unit tests nor the author's end-to-end run could catch this:
every fixture timestamp is constructed in
time.UTC, and thefile://verification appears to have run on a UTC host.Why it matters: the merged table is the deliverable of this issue, and
its primary column is not comparable between the two sources it merges.
Acceptable: normalize at one place. Either force local rows to UTC in
snapshotInfoFromLocal(or fixscanSnapshotRowsto match its twosiblings), or convert at render time in
printSnapshotTable. Sorting isunaffected either way (
time.Timecomparison is absolute), so this is adisplay/serialization fix only. A test with a non-UTC local timestamp
asserting both rows render on the same clock would pin it.
2.
--jsonsilently truncates and silently drops rowsinternal/vaultik/snapshot_list.go:102-107returns immediately afterencoding, so
reportListDrift(:354) never runs in JSON mode. Everysignal it carries is therefore JSON-invisible:
listing.omitted— themaxRemoteOnlyRows= 1000 cap(
:200-203). Past 1000 remote-only snapshots the JSON document istruncated with no indication whatsoever. In table mode this is
correctly reported (
:388-391); in machine-readable mode it is not.listing.unreadable— a snapshot whose manifest is corrupt simplydoes not appear in the JSON array, indistinguishable from one that
does not exist on the destination.
A consumer piping
snapshot list --jsoninto automation cannotdistinguish "these are all the snapshots" from "these are the first 1000
of N, and M more were unreadable." Silent truncation of a listing whose
entire purpose is disaster recovery is the wrong failure mode.
Acceptable: surface both counts in the JSON path — either as a
top-level envelope alongside the rows, or (if the bare-array shape must
be preserved for compatibility) written to
v.Stderrthe same way theunreachable-destination warning already is. Add a test asserting a
truncated/partial listing is detectable from
--jsonoutput.3. The
--jsonstderr workaround is not scoped correctly, and the gap is on the path it was written forwarnRemoteListingFailed(:132-144) correctly routes thelisting-failure warning to
v.Stderrin JSON mode, becauseinternal/log/log.go:72-78builds the logger overos.Stdoutinboth the TTY and JSON-handler branches, and
internal/log/log.go:62sets the default level to
slog.LevelWarn. Solog.Warnis emitted onstdout by default, with no flags.
But two
log.Warncalls on the very same remote-only path were leftunguarded:
internal/vaultik/snapshot_list.go:229-230—log.Warn("Could not describe remote snapshot", ...)internal/vaultik/snapshot_list.go:283-284—log.Warn("Remote manifest has an unparseable timestamp", ...)vaultik snapshot list --jsonwith one corrupt manifest or one badmanifest timestamp therefore emits a JSON log line onto stdout ahead
of the JSON document, and
| jqfails. That is exactly the corruptionthe
v.Stderrworkaround at:134exists to prevent, on exactly thedegradation scenario the PR treats as first-class —
TestListSnapshots_UnreadableManifestDoesNotHideOtherscovers it intable mode only, and the tests cannot see it because
log.Initialize(log.Config{})points the logger at the test process'sreal stdout rather than
env.stdout.I accept the
v.Stderrworkaround itself as reasonable given #82, andfiling rather than fixing #82 drive-by was the right call. The objection
is narrower: within the one function the workaround was added to, the
same hazard was left in two places. Half-applied, it is the kind of
inconsistency that rots — the next reader sees
log.Warnused freelyhere and concludes it is safe.
Acceptable: make the JSON-mode discipline uniform on this path — thread
the
jsonOutputflag intodescribeRemoteOnlySnapshots/remoteSnapshotInfo, or collect these failures and emit them throughthe same writer
warnRemoteListingFailedalready chooses. A test thatpoints the logger at a captured buffer and asserts stdout is
byte-for-byte a JSON document would pin it.
(Noted, not blocking: the pre-existing
log.Warncalls insnapshotInfoFromLocalat:317,:325,:330have the same hazardand are unchanged from
main. They are #82's problem, not this PR's.)Verified clean
Everything below I checked directly rather than taking from the PR body.
Definition of done. All eight items satisfied, plus the five extra
requirements from the manager comment.
The core property (DoD 1).
TestListSnapshots_RemoteWithoutSecretKey(
internal/vaultik/snapshot_list_test.go:211-255) is a genuineregression guard, not a name. It asserts
Config.AgeSecretKeyis emptyup front (so it cannot silently stop testing its premise), asserts
exactly one prefix listing was issued, asserts no fetched key contains
.age, and asserts the remote-only row rendered. Reinstating theAgeSecretKey == ""early return would drivelistStreamCallsto 0 andremove the row — it fails on both counts. The
AgeSecretKeygate isgone from the code entirely.
Privacy constraint (the one that matters most). Fully honored.
calls are
ListStreamandGet(noPutanywhere insnapshot_list.go).remoteSnapshotInfo(:272-295) leavesIDzero, with a doc commentstating why.
--jsonassertsassert.Empty(t, remoteOnly.ID)attest line 497.
internal/snapshot/remotekey.goandinternal/snapshot/manifest.goare byte-identical to
main—RemoteSnapshotKeyand the manifestformat are unchanged.
snapshot.DecodeManifestcall site in the entire repo is insidedownloadManifestByKey(internal/vaultik/snapshot.go:905).Concurrency. No data race.
describeRemoteOnlySnapshots(
:217-260) writes only to distinct indicesfound[i]/ok[i], andgo.moddeclaresgo 1.26.1so loop variables are per-iteration. Thefull suite passes under
-race(script/testsets it). One manifestread cannot abort the listing: the goroutine body returns
nilunconditionally (
:232,:238) and failures are recorded inok, soerrgroupnever cancels —TestListSnapshots_UnreadableManifestDoesNotHideOthersgenuinely verifies this (a real non-zstd payload, asserting the good row
is present and the bad one is counted). Ordering is deterministic:
unknownis sorted before both truncation and dispatch (:196-198),results are reassembled in index order, and the final sort key is
absolute time. The 1000 cap is reported in table mode — but see
blocking finding 2 for JSON mode.
Degradation (DoD 3).
ListSnapshotsreturnsnilon remotefailure, verified by
TestListSnapshots_UnreachableRemoteDegradesandTestListSnapshots_JSONUnreachableRemote.markRemotePresence(
:301-306) is called only in theremoteErr == nilbranch, soRemotePresentstays a nil*bool→null, neverfalse. The*booltype makes "absent" and "unknown" structurallydistinguishable.
reportListDriftis gated onremoteErr == nil(
:114-116), so no drift is claimed from a listing that neverhappened — asserted directly at test line 396.
Behavior preservation. I read the 275 deleted lines against the new
file.
printSnapshotTableandsnapshotInfoFromLocalmoved verbatim;the only table change is the identifier cell, which now falls back to
formatRemoteOnlyIDwhen!LocallyTracked(previously it printedsnap.IDunconditionally, which was always populated becauseLocallyTrackedwas hardcodedtrue). Locally-tracked rows renderidentically.
reportRemoteDrift→reportListDriftdrops no workingcase: the local-only warning, the per-ID list, and the remediation hint
all survive; the remote-only branch was a bare count and is now real
rows. Both
unreadableandomittedare new additions.PR body claim —
origin/fix/sync-snapshot-cleanupis redundant.True. Its tip
332ea26changes exactly one line insyncWithRemote(
Repositories.Snapshots.Delete→deleteSnapshotFromLocalDB), andmainalready carries that change atinternal/vaultik/snapshot.go:1186. Confirmed by readinggit show origin/main:internal/vaultik/snapshot.go. It shows in thethree-dot diff only because the merge base
c24e7e6predates it. Noreal fix is being discarded.
PR body claim — single manifest reader. True.
DecodeManifesthas exactly one call site repo-wide (
snapshot.go:905, insidedownloadManifestByKey). The remainingmanifest.json.zststringoccurrences are: the upload in
internal/snapshot/snapshot.go:508(aPut, not a read), two key-name filters in listing loops(
prune.go:223,snapshot.go:1247), tests, and doc comments.verify.goandinfo.gowere genuinely routed through the helper.snapshot cleanupstring. Gone from all Go source; the onlyremaining occurrences are the TODO entry describing the removal and the
test asserting its absence (
snapshot_list_test.go:367).CleanupLocalSnapshotsis reachable:Prunecalls it atinternal/vaultik/prune.go:82as its first pass, with the commentsaying so. Not dead. Renaming rather than re-adding a duplicate entry
point was the right call.
Nothing weakened.
.golangci.ymlhashes to021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcbasrequired.
Dockerfile,Makefile,.gitea/,script/arebyte-identical to
main(emptygit diff). No deleted tests, not.Skip, no weakened assertions, no file-level//nolint. One new//nolint(helpers.go:76,tagliatelle) is per-site, justified, andmatches the directive already on that struct on
main. No newdependency:
golang.org/x/syncwas already a direct require.Repo policy. No Claude/Anthropic references in commits, PR body,
code, or comments. No attribution trailers (
git log --format='%(trailers)'empty on both commits). Landing commit title ends with
(closes #64).No scope creep — the
downloadManifestByKeyconsolidation is a separatecommit and is explicitly requested by the issue's manager comment.
Inclusive terminology clean.
make fmt-checkclean.TODO.md. Updated in the same commit as the work and factuallyaccurate against the diff. Leaving Next Step as "Triage the stale
remote branches (issue #71)" was correct — the workflow says to
rotate the step you did, and this was not it. Recording #71 as done
would have been a false claim; the entry going to Completed Steps with
Next Step untouched is the honest bookkeeping.
Nits (non-blocking)
internal/vaultik/snapshot_list.go:141-143— in non-JSON modewarnRemoteListingFailedcalls bothlog.Warnandv.UI.Warningfwith the same message. Since the logger writes to stdout at default
level, the user sees the warning twice.
reportRemoteDriftonmainemitted it once.
internal/vaultik/snapshot_list.go:464—formatRemoteOnlyIDiscomputed for every row and discarded for locally-tracked ones. Free
to move inside the
if.internal/vaultik/snapshot_list.go:98—sort.Sliceis not stable.Output is still deterministic here because the input order is, but
sort.SliceStablewould make that property local rather thanincidental, which matters once many remote-only rows share the zero
timestamp from the unparseable-timestamp fallback at
:286.Manager note on the review above. Verdict accepted: FAIL, label set to
needs-rework. All three blocking findings are real and worth fixingbefore this lands.
Two things about the review method are worth recording, because they keep
paying off in this repo:
The gate result was a false positive and the reviewer caught it.
script/cibuildreturned a literalEXIT=0, but the whole Docker buildresolved from layer cache —
RUN make lintandRUN make testbothCACHED, zero stage output. An exit code from a build that ran nothingis not evidence. The reviewer re-ran uncached and confirmed the tree is
genuinely green. This repo has now produced three separate flavors of
false green (wrong linter version twice, and now a cached build), so
treating a bare exit code as proof is not a safe habit here.
Finding 1 is the kind of bug that survives review. Remote-only rows
render
timestamp.UTC()while local rows come fromListRecent, whosescanner at
internal/database/snapshots.go:767doestime.Unix(startedAtUnix, 0)without.UTC()— unlike its twosiblings at lines 200 and 639, which do. Both paths then print through
the same zone-less format string. On a non-UTC host the same snapshot
shows one time when locally tracked and a different time when
remote-only, in the same column, with nothing to indicate why. Every
fixture is
time.UTCand the end-to-end run was evidently on a UTC host,so nothing caught it. For a backup tool where the timestamp is how a user
identifies which snapshot to restore, a silently wrong time is worse than
a missing one.
Note that
snapshots.go:767is a pre-existing inconsistency this PRmerely surfaces. Fix it at a single normalization point rather than
patching the display layer, so the other two call sites cannot drift back.
Finding 2 is the "no silent caps" rule: table mode reports both the
1000-row cap and the unreadable-manifest count, but
--jsonreturns earlybefore
reportListDriftand reports neither. A machine consumer past1000 remote-only snapshots gets a truncated document with no signal. That
is precisely the consumer least able to notice.
Finding 3 is narrow: the
--jsonstderr workaround is right, andfiling #82 instead of fixing the logger drive-by was the right call, but
two
log.Warncalls on the same new path were left unguarded, so a singlecorrupt manifest still breaks
| jq. The tests cannot see it becauselog.Initializepoints at the real process stdout rather than the test'scapture buffer — worth fixing so the test can actually observe stdout.
Everything else the reviewer checked came back clean, including both
factual claims I asked it to verify independently rather than trust:
fix/sync-snapshot-cleanupreally is already onmain(nothing is beingdiscarded), and
downloadManifestByKeyreally is the only remote-manifestreader. The privacy constraint holds — no writes on the listing path, the
human ID never fabricated,
remotekey.goandmanifest.gobyte-identical.Leaving
TODO.md's Next Step unrotated was also correct.Going back to an implementer now; a fresh reviewer will look at the rework.
Rework pushed as
9a45221. All three blocking findings are addressed,each with a test I verified fails without its fix. No finding is
silently skipped and none is rebutted — all three were correct. The PR
body has a matching "Review rework" section.
Gate — forced uncached, both ways
The review was right that the previous
EXIT=0proved nothing: thatbuild resolved entirely from layer cache. This time:
1.
GOFLAGS=-count=1 make check→ literalEXIT=0.-count=1disables the test result cache, so every package genuinelyran. 14
okpackage lines, none marked(cached); the lint stageprinted
0 issues.;fmt-checkclean.2.
BUILDKIT_PROGRESS=plain script/cibuild→ literalEXIT=0.The source
COPYlayer was invalidated by the new commit, so neithergate stage could be cached, and the plain progress output shows both
actually executing:
Every
okline in that stage carries a real duration; none is(cached). The only warning is the pre-existinggomodguarddeprecation notice from the #61 rollout.
.golangci.ymlis unchanged and still hashes to021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb.git diff origin/mainoverDockerfile,Makefile,.gitea/andscript/is empty.Finding 1 — mixed timezones in the TIMESTAMP column
Fixed at the scanner, not the display layer, as directed.
scanSnapshotRows(internal/database/snapshots.go) now decodesstarted_at/completed_atwith.UTC(), matching the two siblingscanners it had drifted from, with a comment stating why the zone is a
decode choice here. Nothing in
snapshot_list.gowas patched tocompensate.
While doing it I also folded
GetIncompleteByHostnameontoscanSnapshotRows: it selects the identical column set and carried averbatim copy of the loop. That was forced by
dupl, which correctlybegan firing the moment the two loops became token-identical — and it
is the right outcome anyway, since it removes the last place the
normalization could drift back out of sync. Three readers, one scanner.
Tests. Two, at both levels:
TestSnapshotTimestampsDecodeAsUTC(internal/database/snapshots_test.go)exercises
GetByID,ListRecent,GetIncompleteSnapshotsandGetIncompleteByHostname, and compares*time.Locationpointersrather than offsets. That makes it host-independent in the strongest
sense:
time.Unixreturnstime.Local, which is never the sameLocationvalue astime.UTCeven on a host whose offset is zero.It would have failed on the UTC machine where this was missed.
TestListSnapshots_TimestampsAreUTCOnNonUTCHostpinstime.Localtoa fixed
+07:13zone via a documented helper (restored int.Cleanup, and the test is deliberately non-parallel — Go runsnon-parallel tests to completion before resuming any parallel one, so
the mutation is not observable elsewhere). It builds a locally tracked
row and a remote-only row from the same instant and asserts they
render the same wall clock in the table and the same string in
--json.Reverting the
.UTC()makes both fail, and the second one prints thebug verbatim:
Finding 2 —
--jsonsilently truncatingTook the stderr route, as recommended. The JSON shape is unchanged,
so this is not a breaking change for consumers. New
reportJSONListingLimitsruns before the encoder (so the warningssurvive even if encoding fails) and writes to
v.Stderr, the samestream the unreachable-destination warning already uses:
Gated on
remoteErr == nil, exactly like the table-mode path, sonothing is claimed from a listing that never happened.
Tests.
TestListSnapshots_JSONReportsTruncationbuilds 1001remote-only snapshots and asserts the document has exactly 1000 rows
and that the truncation is announced — that is the case the review
called out as invisible.
TestListSnapshots_JSONReportsUnreadableManifestscovers the corrupt-manifest count. Both fail without the fix (
"" does not contain "listing truncated"). The 1001-manifest test costs about4s; the suite still finishes well inside the 30s per-package timeout.
Finding 3 — half-applied
--jsonstderr workaroundBoth stragglers now route through one JSON-aware writer,
warnWhileListing, which picksv.Stderrin--jsonmode andlog.Warnotherwise. Its doc comment states the reason and says todelete it, along with the branch in
warnRemoteListingFailed, once #82lands. The logger itself is untouched — #82 remains out of scope.
One structural change was needed to do this safely: those two warnings
fire inside the
errgroupworkers, andv.Stderris not safe forconcurrent writes (a
bytes.Bufferin tests, and-racewould havecaught it). So each worker now records at most one
listingWarninginits own slot and the warnings are emitted after
group.Wait()in keyorder, from a single goroutine. That also makes warning order
deterministic run to run.
remoteSnapshotInforeturns its warninginstead of logging it, for the same reason.
Test.
TestListSnapshots_JSONStdoutIsOnlyTheDocumentdoes what thereview asked for: it redirects the process's own
os.Stdoutto apipe, rebuilds the logger over it, and points the JSON encoder and the
UI writer at the same pipe. That is precisely what
snapshot list --json | jqsees, and it is the only way a test can observe this — asthe review noted,
log.Initializebinds toos.Stdout, not to anywriter the test can inject. The fixture has both a manifest that is not
a zstd stream and a manifest whose timestamp will not parse, so both
call sites are covered.
With the fix reverted it captures the defect exactly — two log lines on
stdout ahead of the array, and the parse fails:
It also asserts the good row and the bad-timestamp row are both present
and the corrupt one is absent, so the degradation behavior is pinned
alongside the parseability.
Optional nits — all three taken
:141-143): thelog.Warnis gone;UI.Warningfalone. Checked before removing it that this losesnothing in
--cron/--quiet: that path isUI.SetQuiet(true),and quiet mode silences everything except Warning and Error. (The
Vaultik.UIdoc comment claiming cron swaps in a discarding writeris stale — the mechanism is
SetQuiet. Not touched here.)formatRemoteOnlyIDcomputed and discarded (:464): the rowcells are now filled in an
if/else, so neither branch's formattingruns for the other.
sort.Slice→sort.SliceStable(:98), with a comment notingthe zero-timestamp fallback makes ties realistic.
Scope
Nothing outside the three findings and the three nits. No existing test
weakened or deleted — the only edits to existing tests are one added
field on the
--jsonrow struct and splittingaddRemoteso a manifestcan carry a raw timestamp string; every prior assertion is intact. Two
new
//nolintdirectives, both per-site and justified:nilerron thedeliberate
return nilin the errgroup worker (returning the errorwould cancel the group and hide every other snapshot), and
gosmopolitanon thetime.Localhelper, where pinning a non-UTC zoneis the entire point. Nothing filed as a new issue; nothing discovered
that warranted one.
TODO.md's Completed Steps entry for #64 now describes the rework;Next Step is still unrotated, per the review.
Re-review: PR #83 — PASS (
merge-ready)Fresh reviewer, independent of the earlier review. Head
9a45221, basemainaf607e3.git merge-base HEAD origin/mainisaf607e3, whichis the current tip of
main, so the branch is a fast-forward with noconflicts. CI on the head commit:
check / check (pull_request),success, 2m24s.
I re-derived every finding below rather than carrying anything forward
from the prior review, and I re-checked the items it had marked clean,
since a new commit landed after it.
Gate — the first result was another false green, as warned
BUILDKIT_PROGRESS=plain script/cibuild→EXIT=0. Worthless. Everystage resolved from layer cache, including the two that matter:
Zero stage output, so neither the linter nor the suite executed. That is
the fourth false green this repo has produced.
Forced uncached through the repo entrypoints —
GOFLAGS=-count=1 make check, i.e.script/check→script/test(go test -race -timeout 30s ./...),script/lint(digest-pinned image from theDockerfilelint stage),
script/fmt-check:okpackage lines, none marked(cached). Longest:internal/database7.773s,internal/vaultik7.988s.0 issues.fmt-check: clean.EXIT=0So the tree is genuinely green, under
-race, at the pinned linterversion. The
gomodguarddeprecation warning is pre-existing from the#61 rollout and is not this PR's.
The three blocking findings — all genuinely fixed, and the tests genuinely catch regressions
I did not take the author's "fails without the fix" claim on trust. I
reverted each fix in a scratch worktree and ran the suite through
make test, then restored (git status --porcelainempty afterwards).1. Timezone normalization — fixed at the scanner, correctly.
internal/database/snapshots.go:733-745—scanSnapshotRowsnow decodesboth
started_atandcompleted_atwith.UTC(), with a commentexplaining that the column is a bare Unix second so the zone is a decode
choice. Nothing in
snapshot_list.gowas patched to compensate, which isthe right layer.
All three read paths are genuinely normalized.
GetByID(:200-204)already did
.UTC()on both fields;ListRecent(:234),GetIncompleteSnapshots(:585) andGetIncompleteByHostname(:614)now all return
r.scanSnapshotRows(rows).The fold of
GetIncompleteByHostnameonto the shared scanner changednothing beyond removing the duplicate:
SELECTlist is byte-identical toGetIncompleteSnapshots' and to whatscanSnapshotRowsscans(
id, hostname, vaultik_version, vaultik_git_revision, started_at, completed_at, file_count, chunk_count, blob_count, total_size, blob_size, compression_ratio). Verified field by field.completedAtUnix *int64, nil checkpreserved.
"scanning snapshot: %w"wrap, sametrailing
rows.Err(), and the caller'sdefer rows.Close()withFatalfis untouched..UTC()on both fields, so thiscall site's behavior is bit-for-bit unchanged.
Revert check: with
.UTC()removed fromscanSnapshotRows,TestSnapshotTimestampsDecodeAsUTCFAILs andTestListSnapshots_TimestampsAreUTCOnNonUTCHostFAILs. Note thisreview host is itself UTC — the database test still failed, because it
compares
*time.Locationpointers rather than offsets, exactly asclaimed. That is the property that makes it host-independent.
2.
--jsontruncation reporting — correct, and the shape really is unchanged.reportJSONListingLimits(internal/vaultik/snapshot_list.go:168-182)writes both counts to
v.Stderr, and is called at:107-109gated onremoteErr == nil— so no nil deref (collectRemoteSnapshotsreturns anil
listingon error) and no claim made from a listing that neverhappened. It runs before
encoder.Encode, so the notices survive anencoding failure.
JSON shape: the rework commit
9a45221touches no struct tag and addsno field.
git diff 0e2929d 9a45221 -- internal/vaultik/helpers.goisempty. Stdout stays a bare array. Confirmed parseable by
decodeListJSON, whichjson.Unmarshals the whole of stdout and failsloudly on any prefix.
Revert check: with the
reportJSONListingLimitscall removed,TestListSnapshots_JSONReportsTruncationFAILs,TestListSnapshots_JSONReportsUnreadableManifestsFAILs, andTestListSnapshots_JSONStdoutIsOnlyTheDocumentFAILs.3. The concurrent warning collection — I looked hard at this and it is correct.
describeRemoteOnlySnapshots(:308-365):found,okandwarningsare allpre-allocated to
len(keys); each worker writes onlyfound[i],warnings[i],ok[i]. No append, no shared map, no resize.iandkeyare per-iteration —go.moddeclaresgo 1.26.1,well past the 1.22 loop-variable change.
group.Wait()at:345beforeany slot is read at
:350-362.warnings[i]: theerror branch builds its own, the success branch stores whatever
remoteSnapshotInforeturned (possibly nil). The emit loop is overfor i := range keysand runs before the!ok[i]continue, soa warning belonging to an unreadable key is still emitted.
remoteSnapshotInfono longer logs on its own (:397-404returns thewarning instead).
unknownissort.Strings-sorted at:277before both the truncation cut and dispatch, and emission is inindex order from the single calling goroutine.
-race(script/testsets-race), which iswhat would have caught concurrent
bytes.Bufferwrites had thestructural change not been made.
TestListSnapshots_JSONStdoutIsOnlyTheDocumentdoes redirect theprocess's stdout, not the env buffer:
captureProcessStdoutsetsos.Stdout = writerand then callslog.Initialize(log.Config{}).internal/log/log.go:73-78readsos.Stdoutat call time in both theTTY and JSON-handler branches, so the rebuilt logger genuinely writes to
the pipe. The encoder and the UI are then pointed at the same pipe. So
the assertion is against what
snapshot list --json | jqactually sees.Revert check: forcing
warnWhileListingto always calllog.Warnmakes that test — and only that test — FAIL.
The five
//nolintdirectivesAll five are per-site, none file-level, and each justification is
accurate:
internal/vaultik/snapshot_list.go:332//nolint:nilerr— correct,not a swallow. The error is not discarded: it is recorded in
warnings[i](emitted verbatim, key and error, at:352) andok[i]stays false, which increments
listing.unreadable, which is reportedin table mode (
reportListDrift,:503-506) and in--jsonmode(
reportJSONListingLimits,:169-174). Returning it would cancel theerrgroup and let one corrupt manifest hide every other snapshot —
which
TestListSnapshots_UnreadableManifestDoesNotHideOtherspins.internal/vaultik/helpers.go:76//nolint:tagliatelle— pre-existingon
main, unchanged.internal/vaultik/snapshot_list_test.go:444//nolint:tagliatelle—the test's mirror of the wire format; asserting on snake_case is the
point.
snapshot_list_test.go:564//nolint:gosmopolitan— pinningtime.Localis literally the mechanism under test.snapshot_list_test.go:586and:756, both//nolint:paralleltest—both accurate. One mutates
time.Local, the otheros.Stdoutplus the global logger. Go runsnon-parallel top-level tests to completion before resuming paused
parallel ones, so neither mutation is observable elsewhere;
-raceagrees.
Previously-clean items, re-verified at the new head
TestListSnapshots_RemoteWithoutSecretKeyasserts
Config.AgeSecretKeyis empty up front, asserts exactly oneprefix listing, asserts no fetched key contains
.age, asserts theremote-only row renders and that neither
otherhostnormediaappears in the output. Reinstating an
AgeSecretKey == ""gate breaksit on several counts. No such gate exists anywhere in the code.
internal/snapshot/remotekey.goandinternal/snapshot/manifest.goare byte-identical tomain(emptygit diff). NoPut,Deleteor upload appears anywhere insnapshot_list.go— the listing path is read-only. The human ID isleft zero for remote-only rows with a doc comment saying why, and
TestListSnapshots_JSONMergedViewassertsEmpty(remoteOnly.ID).RemotePresent *bool.markRemotePresenceis called only in theremoteErr == nilbranch (:93-96), so on an unreachable destinationit stays nil and serializes as
null(
json:"remote_present", noomitempty).TestListSnapshots_JSONUnreachableRemoteassertsNil;TestListSnapshots_JSONMergedViewasserts a realfalsefor alocal-only row. "Absent" and "unknown" stay distinguishable.
snapshot.DecodeManifesthasexactly one non-test call site repo-wide:
internal/vaultik/snapshot.go:905, insidedownloadManifestByKey.The remaining
manifest.json.zstoccurrences are the upload ininternal/snapshot/snapshot.go:508, two key-name filters(
prune.go:223,snapshot.go:1247), and doc comments.verify.goand
info.goare genuinely routed through the helper, andinfo.go's path string was previously built identically, so behavioris preserved.
The optional nits from the prior review
All three taken, and the reasoning behind the first one checks out:
log.WarninwarnRemoteListingFailedis gone.Nothing is lost in
--cron/--quiet.Vaultik.UIis alwaysui.New(os.Stdout)(internal/vaultik/vaultik.go:109) — there is nodiscarding-writer swap anywhere — and quiet mode is
SetQuiet(true),which gates
Beginf/Completef/Infof/Noticef/Detailf/Progressf/Bannerfbut not
WarningforErrorf(internal/ui/ui.go:69-207). Thewarning still reaches a cron mailbox; only the
Infoffollow-up lineis suppressed, as it should be.
formatRemoteOnlyIDis now inside theelsebranch (:584-594).sort.SliceStableat:102with a comment naming the zero-timestamptie source.
Nothing weakened
.golangci.yml→021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb.git diff origin/mainoverDockerfile,Makefile,.gitea/,script/is empty.git diff origin/main --stat -- '*_test.go'is insertions only(+113, +801, 0 deletions).
grep '^-func Test'over the test diff isempty — no test removed. No new
t.Skip(the three in the tree arepre-existing and untouched). No weakened assertion. No file-level
//nolint.Repo policy
comment, doc, or the PR body. No attribution trailers —
git log --format='%(trailers)'is empty on all three commits.Fix timezone drift and --json truncation in snapshot list (closes #64). Ends with(closes #64).make fmt-check: clean.downloadManifestByKeyconsolidation is its owncommit and is explicitly requested by the issue's manager comment
("read the manifest through a single helper"). Issue #82's logger fix
is correctly left alone; the workaround carries a comment naming #82 as
its removal condition.
TODO.mdis updated in the same commit as the work and is factuallyaccurate against the diff, including the rework paragraph. Next Step
left unrotated is still correct: the workflow rotates the step you
did, and this was issue #64, not #71. Rotating would have recorded
#71 as done when it is not.
requirements from the manager comment on #64.
Nits (non-blocking, no action required to merge)
internal/vaultik/snapshot_list_test.go—captureProcessStdouthasno
deferrestoringos.Stdout. The onerequire.NoErrorinside thefnclosure wouldruntime.Goexit()past the restore and pastwriter.Close(), leaving the package'sos.Stdoutpointing at anunread pipe and leaking the drain goroutine — turning a clean failure
into a probable 30s package timeout. Only reachable when the test is
already failing, so it costs diagnosability rather than correctness.
defer func() { os.Stdout = previous }()would close it.internal/vaultik/snapshot_list.go:186—kvPairSizeis apackage-scoped const in
internal/vaultiknamed for a generalstructured-logging concern but declared mid-file between two listing
functions. A local
constinsidewarnWhileListing, or a name tiedto that function, would keep the package namespace tidier.
internal/vaultik/snapshot_list.go:277-282— truncation cutsunknownin remote-key hex order, so the 1000 rows shown arearbitrary with respect to time rather than the newest 1000. This is
inherent (timestamps require the very manifest reads being capped) and
the omission is reported, so it is fine — but the cap is not mentioned
in the README's
snapshot listsection, which is otherwise thorough.9a45221and0e2929dend with(closes #64). The repo'sdefault merge style is squash, so exactly one commit lands and the
duplicate is harmless. Worth avoiding on future reworks only because
it would double-close if the branch were ever merged unsquashed.
Verdict
PASS. The three blocking findings are fixed at the right layer, each
is pinned by a test I confirmed fails when the fix is reverted, the new
concurrent code is race-free with disjoint slots and deterministic
ordering, nothing was weakened, and the tree is green under an uncached
run at the pinned linter version. Label
merge-ready, assignsneak.