e43b5ec07d
gates / gates (push) Successful in 11s
R-87 re-scoped by its own spike and built as Option C. MinAgent 0.129.0 unchanged. THE QUESTION NOTHING ASKED. The weekly check proves the stored bytes are the bytes we stored; it cannot tell us we stored the WRONG thing. A hollow recovery unit backs up cleanly, checks cleanly at 100 percent depth, restores cleanly and gives the customer nothing back - measured on demo-hp 2026-08-31, 120082104 B to 7036 B in one nightly run recorded as a success (R-403). No tier and no cadence asked it. Now offsite-proof does, nightly, on one app. IT DOES NOT prove a restore puts data back into a running app. That stays drill work and 07 section 8 matrix row 4 is NOT moved. THE ACCEPTANCE RULE HAS TWO PARTS AND THE OBVIOUS ONE IS A TRAP. "Check the unit against its own packing list" PASSES a hollow unit, because a hollow unit declares nothing. So: (1) everything declared is present, AND (2) the manifest declares what the app is supposed to have. Part 2 is the whole value. RED-PROOFED: the naive rule makes the hollow-unit test read verdict "pass". THE EXPECTATION COMES FROM INSIDE THE UNIT, never the live box - the snapshot may predate the app's shape, and GetDockerVolumes describes the running app. Database half is DBServiceNames, the same discriminator RestoreFromRecoveryUnit uses. Volume half is ParseComposeNamedVolumes as an EXISTENCE check, not a name match: tars are <project>_<volume>.tar and ResolveDockerVolumeNames derives the project from the compose file's parent dir, which inside a unit is the literal string "compose". Measured on all eight real units on demo-hp the counts match exactly and the naming held every time - but "held on eight" is not "derivable" (R-355). Half a rule that is true beats a whole rule that is invented. THREE OUTCOMES: pass, fail (readable and empty), cannot judge. An app that legitimately has neither a database nor volumes PASSES. RED-PROOFED: alarming on any empty unit makes that test read verdict "fail". IT NEVER WRITES TO THE REPOSITORY and that is asserted on the ARGV as a non-effect: --no-lock, no unlockStale, and m.runner() rather than resticStep so the unlock --remove-all escalation is unreachable. RED-PROOFED: routing it the customer path's way makes the test fail on "unlock" appearing in the argv. IT TAKES acquireRunning ITSELF and skips rather than waits, because RestoreOffboxScratch does not take it (R-408) while offbox_integrity.go states that invariant as universal. DUE-NESS IS PER SNAPSHOT (R-86's model), never per clock. RED-PROOFED: recording a timestamp fails the stored-value test AND breaks the rotation - night 2 re-picks night 1's app. ITS SCRATCH IS A SEPARATE ROOT (backups/offsite-proof) and that is a safety decision, not tidiness: the job deletes its copy on every path, and sharing backups/offsite-restore/<app> would mean a nightly background job deleting the verification copy a CUSTOMER is looking at. It is also invisible to placement, so a proof copy can never be pushed into a live app. SHARED RATHER THAN FORKED: offboxScratchDirIn parameterises the scratch resolver on its ROOT builder, and unitOnlyHeadroom extracts the free-space gate, so the customer path and the proof refuse at the same floor with the same Hungarian sentence. RestoreOffboxScratch's behaviour is unchanged. NEW EVENT offsite_proof_empty, severity error, operator-only - deliberately NOT backup_integrity_failed, whose hub template says the store is DAMAGED. Here the store is sound and the content is absent: different cause, different action. The hub half shipped FIRST, in felhom.eu 1aeaa30 (hub v0.110.0, live and verified), because an unallowlisted type is 400'd and vanishes. 33 new tests, all groups green; full suite 1689 tests, 28 packages, rc=0. All 13 controller gates OK. Five red-proofs run and recorded in REPORT.md. A golden carrying 0.231.0 is OWED - the fleet is on 0.230.0. Viktor's call (R-242).
119 lines
5.5 KiB
Go
119 lines
5.5 KiB
Go
package notify
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// R-87 Group C — the ALARM.
|
|
//
|
|
// It reuses `notifierAgainstHub` / `awaitEvent` from backup_target_notify_test.go rather than adding
|
|
// a second harness: the question is identical (what actually went over the WIRE), and a second
|
|
// recorder is how two tests come to disagree about the same encoding.
|
|
//
|
|
// The severity vocabulary is `{info, warning, error, critical}` and anything else is silently coerced
|
|
// to `info` and mailed to NOBODY — that shipped twice (R-328 on `disk_health_degraded`, R-329 on
|
|
// `app_start_failed`: 91 events stored, zero delivered).
|
|
|
|
// TestR87_FailureEmitsExactlyOneEventWithAValidSeverity — C1.
|
|
func TestR87_FailureEmitsExactlyOneEventWithAValidSeverity(t *testing.T) {
|
|
n, got := notifierAgainstHub(t)
|
|
n.NotifyOffsiteProofEmpty(
|
|
"A(z) kimai legutobbi tavoli mentese olvashato, de nem tartalmazza az alkalmazas adatait.",
|
|
"snapshot a07c36a1, reason database_expected_none_captured")
|
|
|
|
ev := awaitEvent(t, got)
|
|
if ev.EventType != "offsite_proof_empty" {
|
|
t.Fatalf("event_type = %q, want offsite_proof_empty — an unallowlisted type is 400'd by the hub and VANISHES", ev.EventType)
|
|
}
|
|
valid := map[string]bool{"info": true, "warning": true, "error": true, "critical": true}
|
|
if !valid[ev.Severity] {
|
|
t.Fatalf("severity %q is outside the hub's vocabulary — it would be coerced to info and mailed to nobody", ev.Severity)
|
|
}
|
|
if ev.Severity != "error" {
|
|
t.Fatalf("severity = %q, want error: a backup holding none of the customer's data is not a warning", ev.Severity)
|
|
}
|
|
|
|
// EXACTLY ONE. A second event for the same fact is how an operator learns to skim.
|
|
select {
|
|
case extra := <-got:
|
|
t.Fatalf("a failing proof must emit exactly ONE event; a second arrived: %+v", extra)
|
|
case <-time.After(300 * time.Millisecond):
|
|
}
|
|
}
|
|
|
|
// TestR87_MessageSaysIntactButEmptyNotCorrupt — C3.
|
|
//
|
|
// ASCII-ONLY FRAGMENTS WITH A NEGATIVE CONTROL (R-364): an accented grep has returned 0 for strings
|
|
// that were there, so every fragment below is ASCII and one deliberately-absent fragment proves the
|
|
// search can fail.
|
|
func TestR87_MessageSaysIntactButEmptyNotCorrupt(t *testing.T) {
|
|
n, got := notifierAgainstHub(t)
|
|
msg := "A(z) kimai legutobbi tavoli mentese olvashato, de nem tartalmazza az alkalmazas adatait. " +
|
|
"A tarolo nem serult - a mentes keszult el uresen."
|
|
n.NotifyOffsiteProofEmpty(msg, "snapshot a07c36a1")
|
|
ev := awaitEvent(t, got)
|
|
|
|
// POSITIVE: the store is readable AND the content is absent — both halves, or the customer reads
|
|
// this as R-359's damaged store, which has a different cause and a different action.
|
|
for _, frag := range []string{"olvashato", "nem tartalmazza", "nem serult"} {
|
|
if !strings.Contains(ev.Message, frag) {
|
|
t.Fatalf("the message must carry %q; got %q", frag, ev.Message)
|
|
}
|
|
}
|
|
// NEGATIVE CONTROL: prove the search above can fail on this same string.
|
|
if strings.Contains(ev.Message, "ZZZ-NOT-IN-THE-MESSAGE") {
|
|
t.Fatal("negative control matched — the fragment search is not discriminating, so the positives above prove nothing")
|
|
}
|
|
// It must NOT claim damage. These are R-359's own words for the other fault.
|
|
for _, forbidden := range []string{"hibat talalt", "serult lehet"} {
|
|
if strings.Contains(ev.Message, forbidden) {
|
|
t.Fatalf("the message must not say the store is damaged; %q appears in %q", forbidden, ev.Message)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestR87_ProofEventCarriesNoPathAndNoRepositoryURL — units carry portable secrets and the repository
|
|
// URL is a credential-adjacent string. Neither may ride out on an event.
|
|
func TestR87_ProofEventCarriesNoPathAndNoRepositoryURL(t *testing.T) {
|
|
n, got := notifierAgainstHub(t)
|
|
n.NotifyOffsiteProofEmpty("A(z) kimai mentese ures.", "snapshot a07c36a1, reason database_expected_none_captured, expected: db")
|
|
ev := awaitEvent(t, got)
|
|
for _, forbidden := range []string{"/mnt/", "sftp:", "RESTIC_PASSWORD", "ssh_key"} {
|
|
if strings.Contains(ev.Message, forbidden) {
|
|
t.Fatalf("%q must never appear in a customer/operator event; got %q", forbidden, ev.Message)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestR87_PassEmitsNothing — C2, asserted as a NON-EFFECT. The job-level half — that the pass branch
|
|
// never reaches the notifier at all — is D1's AST walk over main.go.
|
|
func TestR87_PassEmitsNothing(t *testing.T) {
|
|
_, got := notifierAgainstHub(t)
|
|
select {
|
|
case ev := <-got:
|
|
t.Fatalf("nothing was called, so nothing may arrive; got %+v", ev)
|
|
case <-time.After(300 * time.Millisecond):
|
|
}
|
|
}
|
|
|
|
// TestR87_NoPerAppCooldownEntryWasAdded — C4, asserted from the controller side.
|
|
//
|
|
// The register lives in the hub (`notify.perAppCooldownEvents`) and adding an entry there is a FENCED
|
|
// act (08 §6.2): the backup family's cooldown is coarse ON PURPOSE so that one full disk produces one
|
|
// mail rather than twenty. This job proves ONE app per night, so the coarse hourly operator cooldown
|
|
// is already the right grain.
|
|
//
|
|
// The controller's half of that contract is asserted here: the event must NOT carry a `stack_name`
|
|
// detail field, because that is the payload shape the hub's per-app keying reads. The app is named in
|
|
// the MESSAGE instead. This fails if someone later routes the type through the per-app path.
|
|
func TestR87_NoPerAppCooldownEntryWasAdded(t *testing.T) {
|
|
n, got := notifierAgainstHub(t)
|
|
n.NotifyOffsiteProofEmpty("A(z) kimai mentese ures.", "snapshot a07c36a1")
|
|
ev := awaitEvent(t, got)
|
|
if strings.Contains(ev.Message, "stack_name") {
|
|
t.Fatalf("offsite_proof_empty must not carry stack_name — that is the field the hub per-app cooldown keys on, and this family is coarse by design; got %s", ev.Message)
|
|
}
|
|
}
|