fcef8e069c
gates / gates (push) Successful in 12s
THE WALK FOUND THREE MORE ENTRY POINTS THAN THE REPORT DID. R-411 named one missing
acquireRunning. Fixing it and then pinning the invariant with an AST walk surfaced FOUR in
total, all of which issued restic commands with no flag:
RestoreOffboxScratch - the reported one
OffboxRestorePrepareFull - the SECOND request in the customer's own two-step full-restore
flow, and the one that actually shells `restic stats`. The UI
reaches it FIRST, so flagging only the restore would have left
the collision reachable by the ordinary path.
RestoreSharesScratch - R-411's exact shape on the shares tier: unlockStale + resticStep,
a live web caller, and its sibling PlaceSharesRestore has always
taken the flag.
RestoreOffbox - no production caller today, but the same dangerous pattern.
Flagged rather than left for a future caller to inherit.
OffsiteInventoryList is REGISTERED EXEMPT with its reason: it issues only `restic snapshots
--json`, measured on demo-hp 2026-08-31 not to take a lock, and flagging it would make
browsing a page refuse during a backup for no safety gain.
THE REAL DELIVERABLE IS THE WALK, not the acquire. offbox_integrity.go:28 asserted "Every
off-site operation takes acquireRunning" since v0.227.0, nothing checked it, and it was false
for months - the ninth instance of this project's most-repeated class. The walk is an AST
pass, not strings.Contains, because a commented-out call still contains the string.
Red-proofed twice: removing the acquire fails it naming RestoreOffboxScratch; an
unregistered fake entry point fails it naming the fake.
R-407: "It NEVER writes to the repository" corrected in place, not deleted (R-360's rule).
`check` takes a lock - and so does `restic stats`, which is the fact nobody had and the one
that made R-411 possible. Both recorded where the next reader will meet them.
R-414: the proof could not run at all on a box with no registered drive. Part 2.1's
determination came out as neither "missed" nor "deliberate": R-356's own test comments say
the scratch resolver "still resolves ... only the DESTINATION moves", so it was OUT OF SCOPE,
and it was never ruled out on state-only grounds - the one comment about a systemDataPath
fallback belonged to PlaceOffsiteRestore, concerned bulk USERDATA, and R-356 overruled even
that. So 07 section 6.3's rule applies and now has a fourth consumer.
The fallback is SCOPED, because the two callers ask different questions and one predicate
answering both is the R-356 defect itself: a UNIT-ONLY restore may fall back to the system
data path (07 section 7 records as FACT that a driveless app's unit already lives there
indefinitely, and that the same-device placement is intended); a FULL restore keeps today's
refusal, because it pulls bulk userdata onto a state-only tier.
And the silence ends either way: a proof that cannot start now records ProofResultCannotRun
rather than an Err, so last_proof_result is never ABSENT - absent already means "controller
too old", and a second meaning on the same field is the StatsKnown trap one level up. It is
recorded WITHOUT advancing per-snapshot due-ness, so the app stays retryable once a drive is
registered.
R-412 leg 1: a per-app push whose unit carried no dump and no tar now says so, at WARN.
Wording only - no guard, and the capture is untouched (08 section 8.2). Leg 2 stays OPEN.
16 new tests, 1689 -> 1705. Full suite 28 packages rc=0, all 13 controller gates OK.
Red-proofs run and reverted byte-identical for A3/B1 (twice), C1 and D1.
116 lines
4.2 KiB
Go
116 lines
4.2 KiB
Go
package backup
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// R-412 leg 1 — a push that carried nothing must not read as a plain success.
|
|
//
|
|
// RUN-LEVEL on purpose, and the sibling R-234 file records why: its first version asserted the
|
|
// capture helper alone, and its red-proof passed while the defect was untouched. The line under test
|
|
// is emitted inside the per-app loop of the real run, so the test drives the real run and reads the
|
|
// real logger.
|
|
//
|
|
// WORDING ONLY. This asserts what the run SAYS, not that it refuses — whether the push should re-read
|
|
// the unit before sending is R-412 leg 2 and is deliberately still open.
|
|
|
|
// writeUnitManifest gives a unit a manifest declaring exactly what is asked for.
|
|
func writeUnitManifest(t *testing.T, unitDir string, dbDumps, volDumps []string) {
|
|
t.Helper()
|
|
b, err := json.Marshal(RecoveryManifest{DBDumps: dbDumps, VolumeDumps: volDumps})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(unitDir, "manifest.json"), b, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// TestR412a_EmptyPushDoesNotReadAsAPlainSuccess — D1.
|
|
//
|
|
// RED-PROOF (run 2026-09-01, recorded in REPORT.md): restoring the single unconditional
|
|
// `[INFO] backed up %s (%s, %d mandatory path(s))` makes this fail — the run logs a plain success over
|
|
// a snapshot holding none of the app's data, which is exactly what was measured on demo-hp on
|
|
// 2026-08-31.
|
|
func TestR412a_EmptyPushDoesNotReadAsAPlainSuccess(t *testing.T) {
|
|
drive := t.TempDir()
|
|
m, sett, prov := classifiedOffboxManager(t, drive)
|
|
|
|
var buf bytes.Buffer
|
|
m.logger = log.New(&buf, "", 0)
|
|
|
|
// `hollow` has a unit whose manifest declares NOTHING — the R-403 shape.
|
|
hollow := mkUnit(t, drive, "hollow")
|
|
writeUnitManifest(t, hollow, nil, nil)
|
|
prov.hdd["hollow"] = drive
|
|
prov.has["hollow"] = true
|
|
|
|
// `sound` has a unit that declares real data, so the contrast is in the same run.
|
|
sound := mkUnit(t, drive, "sound")
|
|
writeUnitManifest(t, sound, []string{"sound-postgres.sql"}, []string{"sound_data.tar"})
|
|
prov.hdd["sound"] = drive
|
|
prov.has["sound"] = true
|
|
|
|
prov.deployed = map[string]bool{"hollow": true, "sound": true}
|
|
_ = sett.SetAppOffbox("hollow", true)
|
|
_ = sett.SetAppOffbox("sound", true)
|
|
|
|
cap := &backupCapture{}
|
|
m.SetOffboxRunner(cap.runner())
|
|
if err := m.RunOffboxBackup(context.Background()); err != nil {
|
|
t.Fatalf("the run itself must still succeed — this is a wording change, not a guard: %v", err)
|
|
}
|
|
|
|
out := buf.String()
|
|
|
|
// POSITIVE CONTROL FIRST: the run must actually have logged about both apps, or every assertion
|
|
// below is over an empty string.
|
|
if !strings.Contains(out, "hollow") || !strings.Contains(out, "sound") {
|
|
t.Fatalf("the run logged about neither app — the assertions below would prove nothing.\n%s", out)
|
|
}
|
|
// NEGATIVE CONTROL: a string that cannot be there.
|
|
if strings.Contains(out, "ZZZ-NOT-IN-THE-LOG") {
|
|
t.Fatal("negative control matched — the search is not discriminating")
|
|
}
|
|
|
|
// The hollow app's line must say what it did NOT carry. ASCII fragments (R-364).
|
|
var hollowLine string
|
|
for _, l := range strings.Split(out, "\n") {
|
|
if strings.Contains(l, "backed up hollow") {
|
|
hollowLine = l
|
|
}
|
|
}
|
|
if hollowLine == "" {
|
|
t.Fatalf("no per-app push line for the hollow app at all.\n%s", out)
|
|
}
|
|
for _, frag := range []string{"NO database dump", "NO volume tar", "none of the app"} {
|
|
if !strings.Contains(hollowLine, frag) {
|
|
t.Fatalf("the hollow push line must say what it did not carry; %q missing from:\n %s", frag, hollowLine)
|
|
}
|
|
}
|
|
if strings.Contains(hollowLine, "[INFO]") {
|
|
t.Fatalf("a push carrying no data must not be logged at INFO like an ordinary success:\n %s", hollowLine)
|
|
}
|
|
|
|
// And the SOUND app's line must be untouched — the change must not relabel healthy runs.
|
|
var soundLine string
|
|
for _, l := range strings.Split(out, "\n") {
|
|
if strings.Contains(l, "backed up sound") {
|
|
soundLine = l
|
|
}
|
|
}
|
|
if soundLine == "" {
|
|
t.Fatalf("no per-app push line for the sound app.\n%s", out)
|
|
}
|
|
if strings.Contains(soundLine, "NO database dump") {
|
|
t.Fatalf("a healthy push must NOT carry the empty-push wording — a warning that fires on everything costs the same as the comforting lie it replaces:\n %s", soundLine)
|
|
}
|
|
}
|