hub 0.165.0 source: a household's mail list is never overwritten by a box default, a box reads it back (R-1199 reopened); host_swap_short only when swap is really short (R-1216, D28 = A); host-swap-rule gate with 4 decoys; 03/05/08 updated
gates / gates (push) Successful in 15m54s

This commit is contained in:
2026-10-22 12:16:19 +02:00
parent a92837410a
commit 5ae5984cf9
18 changed files with 653 additions and 24 deletions
+9 -4
View File
@@ -655,7 +655,7 @@ run as a **journaled reconcile job**; a mid-flight failure is compensating-rolle
the just-restored guest — allowed unsigned per §4, same-transaction provenance). They diverge in
the *archive* and the *back half*, **and in identity policy** (below).
**Guest swap = guest RAM, and the host's swap behind it (R-1192; agent `main`, unreleased — the release is D26).**
**Guest swap = guest RAM, and the host's swap behind it (R-1192; agent 0.162.0, released and vouched 2026-10-22).**
`[FACT]` 2026-10-21 (`audits/fixes-2026-10-21/P4-freeze/`): Proxmox gives an LXC a 512 MB swap cap when nothing sets
`swap`, and the agent never set it; on the 8 GB minimum machine (guest 4096 MB) the household's first uploads filled the
guest with its swap full, the kernel could evict only program code, and the box froze (memory „full" pressure 96 %, load
@@ -664,9 +664,14 @@ resize (`POST /guest/memory`), and once a minute after agent start for every poo
no restart, never lowers). Proven on a fresh box the night of 2026-10-21 (`audits/night-2026-10-21/P1-soak/`: 512 →
4096 one minute after the fixture agent started; the 2-hour household load then answered every 10-second probe). **The
host must have swap behind it:** our installer gives an 8 GB / 256 GB machine 7.75 GB (measured the same night); a BYO
host may have none. The agent reports `host.host_swap_mb` and `host.guest_swap_mb` on every host report, logs one WARN
when the first is below the second, and the hub mails the operator once (`host_swap_short`, operator-only); the
controller's install page adds „A gépen nincs elég cserehely (swap)…" when memory is tight and the agent says so. The
host may have none. The agent reports `host.host_swap_mb` and `host.guest_swap_mb` on every host report and logs one WARN
when the first is below the second (its own, strict line — a log, nothing more). **The judgement is the hub's and the
box's, by one rule (R-1216, D28 = A, operator 2026-10-22):** short = no host swap, or less than 90 % of min(the guests'
swap allowance, 8192 MB) — 8192 MB being the most Proxmox's installer gives any machine (`Install.pm compute_swapsize`:
the RAM, at least 4096 MB on a disk ≥ 64 GB, at most 8192 MB). The hub mails the operator once (`host_swap_short`,
operator-only); the controller's install page adds „A gépen nincs elég cserehely (swap)…" when memory is tight and the
rule says short. One case table in both repos, byte-identical (`scripts/host_swap_rule_gate.py`). The first rule (host
swap < the guests' whole allowance) flagged every machine with a guest above 8 GB; the 8 GB soak used 2.7 GB. The
agent never creates or enlarges host swap — changing a host its owner set up is a decision (register).
**Identity reset is scenario-specific — this is a correctness boundary, not a detail.** "Reset
@@ -331,7 +331,9 @@ customer instead of a single controller.
- **DROP both** `infra_backup_versions` (current/live) **and** `infra_backups` (legacy mirror) — the DR
recipe, tenantsync and the escrow blob replace them (R-105).
- **KEEP** `customer_configs`, `events`, `customer_notifications`, `notification_log`,
`app_telemetry`, `app_log_issues`.
`app_telemetry`, `app_log_issues`. `customer_notifications.household_saved` (hub 0.165.0, R-1199 reopened) marks a
list the household chose on a box; a box's default never overwrites it, and a box reads it back with `GET
/api/v1/preferences` (customer-scoped like the save). The rule is in `08` (the 2026-10-22 `[DESIGN]` paragraph).
- **Authz cleanup the cutover enables:** several endpoints today use global-or-any-customer-key auth
rather than customer-scoped (the infra-backup GETs, `/notify`). Most retire with the infra-backup
push; any that carry over should scope to the resolved host/guest → customer under §2.
+11 -1
View File
@@ -504,7 +504,17 @@ may reverse).** Measured: a fresh box shows its default list ticked, but pushed
page, so the hub kept mailing by an older stored list (Tester4: saved 2026-10-18, without disk warning, drive
disconnected and second-drive failure — 1 household alarm of 9 on the rehearsal day). Now a box whose household never
saved a choice pushes the default list at start (no address — the hub keeps the stored one); a saved choice is pushed
only as before (R-922). Controller `ba22f9b`, unreleased.
only as before (R-922). Controller `ba22f9b`, shipped in 0.338.0.
**`[DESIGN]` 2026-10-22 — a household's saved list is never overwritten (R-1199 reopened; the day brief).** `[FACT]`
0.338.0's push overwrote demo-felhom's own saved list (13 events, written by an earlier box) with the 17 defaults at
its first start, and repeated at every start. So the hub now records WHO chose a list (`customer_notifications.
household_saved`, set only by a push the box declares `source: household` — the settings page, or the re-push of a
list saved on the box — and never cleared) and refuses any other push over a household's list (`{"status":"kept"}`;
an older controller's push with neither address nor clear counts as not the household's). A box with no list of its
own READS the hub's first (`GET /api/v1/preferences`, self-scoped): a household's list is adopted and shown, nothing
pushed; only when no household ever saved one does the box push its default, once, and save it. Hub 0.165.0,
controller 0.339.0. Existing rows were marked once: written after creation = a box wrote it (`household_prefs.go`).
**What the operator still gets:** everything, unchanged — the operator channel never reads the household's switches
(`processOperator` is independent of `enabled_events`).
+22
View File
@@ -1,3 +1,25 @@
## v0.165.0 (unreleased — built 2026-10-22; the deploy is the operator's yes) — the household's mail list is never overwritten (R-1199 reopened); `host_swap_short` only when swap is really short (R-1216, D28 = A)
- **R-1199 reopened.** `customer_notifications.household_saved` (new column, `store/household_prefs.go`): set ONLY by a
push the box declares `source: household` (controller ≥ 0.339.0: the settings page, or the re-push of a list saved on
the box), never cleared. A push that is not the household's (`source: default`, or an older controller's push with
neither address nor clear — 0.338.0's start-up default) never overwrites a household's list: 200
`{"status":"kept","household_saved":true}`. New `GET /api/v1/preferences?customer_id=` (customer-scoped like the save;
route table updated): `exists`, `household_saved`, the list, the cooldown and the address, so a box with no list of
its own adopts the household's. **One-time backfill** at start (guard `r1199_household_saved_backfill`): a row written
after its creation was written by a box → marked; read on the live hub before the change: Tester-2 and Tester4
(seeded, never pushed) → false; demo-felhom, demo-hp, tester-1, `_resend-rotation-test` → true. Each verdict is
logged. Measured case: demo-felhom's own 13-event list became controller 0.338.0's 17 defaults at 07:54:55 UTC.
Tests `TestR1199Reopened_*` (api; A and A-legacy red-proved), `TestHouseholdSaved_BackfillMarksPushedRowsOnce`
(store, red-proved).
- **R-1216 / D28 = A (operator 2026-10-22).** `host_swap_short` = no host swap, or less than 90 % of min(the guests'
swap allowance, 8192 MB) — 8192 MB is the most Proxmox's installer gives (`Install.pm compute_swapsize`, read from the
9.2-1 ISO); since the guests fit in the host's RAM, the host's RAM is not needed. The morning's rule (host swap < the
guests' whole allowance) flagged demo-hp (8191 vs 25898) and demo-felhom (8191 vs 12288); at their next report each
gets ONE `host_swap_ok` (edge-triggered, operator-only). The alarm text says the new rule. Shared case table
`internal/api/testdata/host_swap_rule_cases.json`, byte-identical in the controller (`scripts/host_swap_rule_gate.py`,
4 decoys). Tests `TestR1216_HostSwapRuleCases`, `TestR1216_TheMorningsAlarmClearsOnceAtTheNextReport` (red-proved).
## v0.164.0 (2026-10-22) — DEPLOYED on the operator's morning yes (built the night of 2026-10-21, image sha256:931594e333a4e8a9784eff2fb144d022ab46f89dd6a865a93e6100dc28dbd9dc)
- **R-1192 follow-up — `host_swap_short` (operator-only).** The agent's guest swap fix (swap = RAM) moves a memory burst
+20 -2
View File
@@ -392,6 +392,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.handleNotify(w, r)
case r.Method == http.MethodPost && path == "/preferences":
h.handleSavePreferences(w, r)
case r.Method == http.MethodGet && path == "/preferences":
h.handleGetPreferences(w, r) // R-1199 reopened: a box with no local list reads the household's
case r.Method == http.MethodGet && path == "/customers":
h.handleCustomers(w, r)
case r.Method == http.MethodGet && strings.HasPrefix(path, "/customers/"):
@@ -2770,6 +2772,10 @@ func (h *Handler) handleSavePreferences(w http.ResponseWriter, r *http.Request)
// household deliberately cleared its address on the dashboard. Omitted (false) on every other push —
// including every push from a controller older than the one that emits it.
EmailCleared bool `json:"email_cleared"`
// Source (R-1199 reopened, controller ≥ 0.339.0): "household" — the household saved this list on the box
// (the settings page, or the re-push of a list saved there); "default" — the box's own default, sent because
// nothing was saved and the hub holds no household list. Absent = an older controller (prefsPushIsHousehold).
Source string `json:"source"`
}
if err := json.Unmarshal(body, &payload); err != nil || payload.CustomerID == "" {
http.Error(w, "Invalid payload: customer_id required", http.StatusBadRequest)
@@ -2791,6 +2797,18 @@ func (h *Handler) handleSavePreferences(w http.ResponseWriter, r *http.Request)
// as its own flag. A flag beside a NON-empty address is an ordinary update (the address wins). Pinned by
// TestSavePreferences_DeliberateClearDeletesAddress, TestSavePreferences_EmptyEmailCannotClobber and
// TestSavePreferences_ClearFlagWithAddressIsAnUpdate.
// R-1199 reopened: a push that is not the household's own save never overwrites a list the household saved.
household := prefsPushIsHousehold(payload.Source, payload.Email, payload.EmailCleared)
if !household {
if existing, err := h.store.GetNotificationPrefs(payload.CustomerID); err == nil && existing != nil && existing.HouseholdSaved {
h.logger.Printf("[INFO] Notification prefs push for %s (source=%q) not applied — the household saved its own list (R-1199)", payload.CustomerID, payload.Source)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"kept","household_saved":true}`))
return
}
}
saveEmail := payload.Email
if saveEmail == "" && payload.EmailCleared {
h.logger.Printf("[INFO] Notification prefs push for %s: household cleared its mail address — deleted", payload.CustomerID)
@@ -2801,13 +2819,13 @@ func (h *Handler) handleSavePreferences(w http.ResponseWriter, r *http.Request)
}
}
if err := h.store.SaveNotificationPrefs(payload.CustomerID, saveEmail, payload.EnabledEvents, payload.CooldownHours); err != nil {
if err := h.store.SaveNotificationPrefsAs(payload.CustomerID, saveEmail, payload.EnabledEvents, payload.CooldownHours, household); err != nil {
h.logger.Printf("[ERROR] Failed to save notification prefs for %s: %v", payload.CustomerID, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
h.logger.Printf("[INFO] Notification preferences updated for %s: address set=%t, events=%v", payload.CustomerID, saveEmail != "", payload.EnabledEvents) // never the address (R-922)
h.logger.Printf("[INFO] Notification preferences updated for %s: address set=%t, household=%t, events=%v", payload.CustomerID, saveEmail != "", household, payload.EnabledEvents) // never the address (R-922)
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
}
+31 -5
View File
@@ -17,11 +17,36 @@ const (
eventHostSwapOK = "host_swap_ok"
)
// installerMaxSwapMB is the most host swap Proxmox's installer gives any machine (Install.pm compute_swapsize: the
// RAM, at least 4096 MB on a disk of 64 GB or more, at most 8192 MB). Read from the 9.2-1 ISO on 2026-10-22.
const installerMaxSwapMB = 8192
// hostSwapShort is the R-1216 rule (D28 = A, operator 2026-10-22): a host is short of swap when it has none, or when
// its swap is below 90 % of the smaller of its guests' swap allowance and what the installer gives. Because the guests
// fit in the host's RAM, „what the installer gives a machine of that RAM" is never smaller than the guests' allowance
// below 8 GB — so the host's RAM is not needed. The morning's first rule (host swap < the guests' whole allowance)
// flagged every machine with a guest above 8 GB; the 8 GB soak's burst used 2.7 GB.
// The CONTRACT is testdata/host_swap_rule_cases.json, byte-identical in felhom-controller (the install page's sentence)
// — felhom.eu scripts/host_swap_rule_gate.py; TestR1216_HostSwapRuleCases runs it here.
func hostSwapShort(hostMB, guestMB int64) bool {
if hostMB <= 0 {
return true
}
need := guestMB
if need > installerMaxSwapMB {
need = installerMaxSwapMB
}
if need <= 0 {
return false
}
return hostMB*10 < need*9
}
func (h *Handler) checkHostSwap(hostID, custID string, hostMB, guestMB *int64) {
if hostMB == nil || guestMB == nil || custID == "" {
return // an older agent: not evaluated
}
short := *guestMB > 0 && *hostMB < *guestMB
short := hostSwapShort(*hostMB, *guestMB)
prev, err := h.store.LatestHostSwapEvent(custID, hostID)
if err != nil {
h.logger.Printf("[WARN] host %s swap check: reading the last swap event failed: %v", hostID, err)
@@ -32,12 +57,13 @@ func (h *Handler) checkHostSwap(hostID, custID string, hostMB, guestMB *int64) {
switch {
case short && !wasShort:
typ, sev = eventHostSwapShort, "warning"
msg = fmt.Sprintf("Host %s: its swap (%d MB) is below its guests' swap allowance (%d MB). The guest swap fix needs swap on the host behind it; "+
"without it a memory burst — a big first upload on an 8 GB box — can freeze the box for minutes (R-1192). Add swap on the host "+
"(our installer gives an 8 GB machine 7.75 GB) or lower the guest's RAM. The household cannot fix this.", hostID, *hostMB, *guestMB)
msg = fmt.Sprintf("Host %s: its swap (%d MB) is too small for its guests' swap allowance (%d MB) — below what our installer "+
"would give (the guests' allowance, at most 8192 MB). The guest swap fix needs swap on the host behind it; without it a "+
"memory burst — a big first upload on an 8 GB box — can freeze the box for minutes (R-1192). Add swap on the host or lower "+
"the guest's RAM. The household cannot fix this.", hostID, *hostMB, *guestMB)
case !short && wasShort:
typ, sev = eventHostSwapOK, "info"
msg = fmt.Sprintf("Host %s: its swap (%d MB) covers its guests' swap allowance (%d MB) again.", hostID, *hostMB, *guestMB)
msg = fmt.Sprintf("Host %s: its swap (%d MB) is enough for its guests' swap allowance (%d MB) again.", hostID, *hostMB, *guestMB)
default:
return
}
+58
View File
@@ -0,0 +1,58 @@
package api
import (
"encoding/json"
"net/http"
"os"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// R-1216 / D28 = A: the shared case table (byte-identical in felhom-controller, felhom.eu scripts/host_swap_rule_gate.py).
// RED-PROOF: with the morning's rule (`guestMB > 0 && hostMB < guestMB`) the demo-hp, demo-felhom and 32 GB rows fail
// (they read short) and the „no swap, no guest" row fails (not short) — seen failing 2026-10-22.
func TestR1216_HostSwapRuleCases(t *testing.T) {
b, err := os.ReadFile("testdata/host_swap_rule_cases.json")
if err != nil {
t.Fatal(err)
}
var f struct {
Cases []struct {
Name string `json:"name"`
Host int64 `json:"host_swap_mb"`
Guest int64 `json:"guest_swap_mb"`
Short bool `json:"short"`
} `json:"cases"`
}
if err := json.Unmarshal(b, &f); err != nil || len(f.Cases) < 10 {
t.Fatalf("case table: %v (%d cases)", err, len(f.Cases))
}
for _, c := range f.Cases {
if got := hostSwapShort(c.Host, c.Guest); got != c.Short {
t.Errorf("%s: host %d / guests %d → short=%t, want %t", c.Name, c.Host, c.Guest, got, c.Short)
}
}
}
// The consequence: a host that this morning's rule flagged (demo-felhom: 8191 vs 12288) gets ONE host_swap_ok at its
// next report under the new rule, and no new alarm after.
func TestR1216_TheMorningsAlarmClearsOnceAtTheNextReport(t *testing.T) {
h, st, _ := newTestHandler(t)
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "p"})
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
if _, err := st.SaveEvent("c1", eventHostSwapShort, "warning", "morning", `{"host_id":"h1","host_swap_mb":8191,"guest_swap_mb":12288}`, "hub"); err != nil {
t.Fatal(err)
}
for i := 0; i < 3; i++ {
if rr := do(h, http.MethodPost, "/host-report", "HKEY", swapReportBody("h1", 8191, 12288)); rr.Code != http.StatusOK {
t.Fatalf("host-report = %d", rr.Code)
}
}
ok, _ := st.GetEventsByType("c1", eventHostSwapOK, time.Time{})
short, _ := st.GetEventsByType("c1", eventHostSwapShort, time.Time{})
if len(ok) != 1 || len(short) != 1 {
t.Fatalf("host_swap_ok = %d (want 1), host_swap_short = %d (want the morning's 1 only)", len(ok), len(short))
}
}
+71
View File
@@ -0,0 +1,71 @@
package api
import (
"encoding/json"
"net/http"
)
// R-1199 reopened (2026-10-22). See store/household_prefs.go for the measured case (demo-felhom's list overwritten by
// controller 0.338.0's start-up default push). Two halves live here:
//
// - prefsPushIsHousehold: which pushes are the household's own save. A controller ≥ 0.339.0 says so (`source`). An
// older one does not: its pushes with an address or a clear are the household's (only a saved list carries
// them, R-922); a push with neither is either a page save without an address or 0.338.0's default push, and the
// two cannot be told apart — it is treated as NOT the household's, so it never overwrites a household's list
// (the cost: on a box still on ≤ 0.338.0, a household that saves without an address does not change a list it
// saved earlier, until the box takes the release that sends `source`).
// - handleGetPreferences: a box with no local list reads the household's list and adopts it, instead of pushing
// its default over it. Self-scoped like the save: a per-customer key reads only its own customer.
//
// Pinned by TestR1199Reopened_* (household_prefs_test.go).
func prefsPushIsHousehold(source, email string, emailCleared bool) bool {
switch source {
case "household":
return true
case "default":
return false
}
return email != "" || emailCleared
}
// handleGetPreferences answers GET /api/v1/preferences?customer_id=<id> with the stored list and whether the
// household saved it. `exists` false = no row. The address is returned only to the household's own box key (or the
// global key) — the box shows it on its own page.
func (h *Handler) handleGetPreferences(w http.ResponseWriter, r *http.Request) {
authCustomerID, isGlobal, ok := h.checkAuthCustomer(r)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
customerID := r.URL.Query().Get("customer_id")
if customerID == "" {
http.Error(w, "customer_id required", http.StatusBadRequest)
return
}
if !isGlobal && authCustomerID != customerID {
http.Error(w, "Forbidden: customer_id does not match the key", http.StatusForbidden)
return
}
prefs, err := h.store.GetNotificationPrefs(customerID)
if err != nil {
h.logger.Printf("[ERROR] Reading notification prefs for %s: %v", customerID, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
out := struct {
Exists bool `json:"exists"`
HouseholdSaved bool `json:"household_saved"`
Email string `json:"email"`
EnabledEvents []string `json:"enabled_events"`
CooldownHours int `json:"cooldown_hours"`
}{EnabledEvents: []string{}}
if prefs != nil {
out.Exists, out.HouseholdSaved, out.Email, out.CooldownHours = true, prefs.HouseholdSaved, prefs.Email, prefs.CooldownHours
if prefs.EnabledEvents != nil {
out.EnabledEvents = prefs.EnabledEvents
}
}
h.logger.Printf("[DEBUG] Notification prefs read for %s: exists=%t household_saved=%t", customerID, out.Exists, out.HouseholdSaved)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(out)
}
+123
View File
@@ -0,0 +1,123 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"testing"
)
// R-1199 reopened (2026-10-22): a push that is not the household's own save never overwrites a list the household
// saved (demo-felhom's 13-event list became controller 0.338.0's default at 07:54:55 UTC); a box reads the household's
// list instead (GET /preferences, self-scoped).
// RED-PROOF: with the `!household && existing.HouseholdSaved` refusal removed from handleSavePreferences, A and A-legacy
// fail (the default replaces the household's list) — seen failing 2026-10-22.
var householdList = []string{"backup_failed", "disk_warning", "node_down"}
func eventsOf(t *testing.T, h *Handler, key string) (saved bool, events []string) {
t.Helper()
rr := do(h, http.MethodGet, "/preferences?customer_id=c1", key, "")
if rr.Code != http.StatusOK {
t.Fatalf("GET /preferences = %d %s", rr.Code, rr.Body.String())
}
var out struct {
Exists bool `json:"exists"`
HouseholdSaved bool `json:"household_saved"`
EnabledEvents []string `json:"enabled_events"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v", err)
}
return out.HouseholdSaved, out.EnabledEvents
}
// A: the household saved its list (on an earlier box); this box pushes its default → the hub keeps the household's.
func TestR1199Reopened_A_ADefaultNeverOverwritesTheHouseholdsList(t *testing.T) {
h, st := newEventTestHandler(t)
if err := st.SaveNotificationPrefsAs("c1", "family@example.hu", householdList, 6, true); err != nil {
t.Fatal(err)
}
rr := do(h, http.MethodPost, "/preferences", "ckey",
`{"customer_id":"c1","email":"","enabled_events":["backup_failed","disk_warning","node_down","storage_disconnected","crossdrive_failed"],"cooldown_hours":6,"source":"default"}`)
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), `"kept"`) {
t.Fatalf("a default push over a household list: %d %s, want 200 kept", rr.Code, rr.Body.String())
}
p, _ := st.GetNotificationPrefs("c1")
if len(p.EnabledEvents) != 3 || !p.HouseholdSaved || p.Email != "family@example.hu" {
t.Fatalf("the household's list was changed: %+v", p)
}
}
// A-legacy: controller 0.338.0 sends no `source`; its start-up default push (no address, no clear) is not the
// household's either — the exact push that overwrote demo-felhom's list.
func TestR1199Reopened_A_The0338DefaultPushIsKeptOutToo(t *testing.T) {
h, st := newEventTestHandler(t)
st.SaveNotificationPrefsAs("c1", "family@example.hu", householdList, 6, true)
rr := do(h, http.MethodPost, "/preferences", "ckey",
`{"customer_id":"c1","email":"","enabled_events":["backup_failed"],"cooldown_hours":6}`)
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), `"kept"`) {
t.Fatalf("0.338.0's push: %d %s, want kept", rr.Code, rr.Body.String())
}
if p, _ := st.GetNotificationPrefs("c1"); len(p.EnabledEvents) != 3 {
t.Fatalf("0.338.0's default push changed the household's list: %+v", p.EnabledEvents)
}
}
// B: no household ever saved (the row was seeded at the claim) → the box's default is applied, still not marked.
func TestR1199Reopened_B_ASeededListTakesTheBoxsDefault(t *testing.T) {
h, st := newEventTestHandler(t)
if ok, err := st.SeedNotificationPrefs("c1", "family@example.hu", []string{"node_down"}); err != nil || !ok {
t.Fatalf("seed: %v %v", ok, err)
}
rr := do(h, http.MethodPost, "/preferences", "ckey",
`{"customer_id":"c1","email":"","enabled_events":["backup_failed","disk_warning"],"cooldown_hours":6,"source":"default"}`)
if rr.Code != http.StatusOK || strings.Contains(rr.Body.String(), "kept") {
t.Fatalf("default over a seed: %d %s, want applied", rr.Code, rr.Body.String())
}
saved, ev := eventsOf(t, h, "ckey")
if saved || len(ev) != 2 {
t.Fatalf("after the default: household_saved=%t events=%v, want false + the 2 defaults", saved, ev)
}
if p, _ := st.GetNotificationPrefs("c1"); p.Email != "family@example.hu" {
t.Fatal("the seeded address must stay (no-clobber guard)")
}
}
// C: the household saves on the box → applied and marked; a later default does not undo it.
func TestR1199Reopened_C_AHouseholdSaveIsAppliedAndMarked(t *testing.T) {
h, st := newEventTestHandler(t)
st.SeedNotificationPrefs("c1", "family@example.hu", []string{"node_down"})
if rr := do(h, http.MethodPost, "/preferences", "ckey",
`{"customer_id":"c1","email":"","enabled_events":["backup_failed"],"cooldown_hours":6,"source":"household"}`); rr.Code != http.StatusOK {
t.Fatalf("household save: %d", rr.Code)
}
if saved, ev := eventsOf(t, h, "ckey"); !saved || len(ev) != 1 {
t.Fatalf("after the household's save: %t %v", saved, ev)
}
do(h, http.MethodPost, "/preferences", "ckey", `{"customer_id":"c1","enabled_events":["node_down","disk_warning"],"source":"default"}`)
if _, ev := eventsOf(t, h, "ckey"); len(ev) != 1 || ev[0] != "backup_failed" {
t.Fatalf("a default after the household's save changed it: %v", ev)
}
// An older controller's push WITH an address is the household's (only a saved list carries one).
do(h, http.MethodPost, "/preferences", "ckey", `{"customer_id":"c1","email":"new@example.hu","enabled_events":["node_down"]}`)
if p, _ := st.GetNotificationPrefs("c1"); p.Email != "new@example.hu" || len(p.EnabledEvents) != 1 || p.EnabledEvents[0] != "node_down" {
t.Fatalf("an older controller's address push must still apply: %+v", p)
}
}
// The read is self-scoped: another household's key is refused; no row → exists false.
func TestR1199Reopened_TheReadIsSelfScoped(t *testing.T) {
h, st := newTwoCustomerHandler(t)
if rr := do(h, http.MethodGet, "/preferences?customer_id=c1", "c2key", ""); rr.Code != http.StatusForbidden {
t.Fatalf("c2 reading c1: %d, want 403", rr.Code)
}
if rr := do(h, http.MethodGet, "/preferences?customer_id=c1", "", ""); rr.Code != http.StatusUnauthorized {
t.Fatalf("no key: %d, want 401", rr.Code)
}
rr := do(h, http.MethodGet, "/preferences?customer_id=c2", "c2key", "")
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), `"exists":false`) {
t.Fatalf("c2 has no row: %d %s", rr.Code, rr.Body.String())
}
_ = st
}
+1
View File
@@ -64,6 +64,7 @@ var apiRoutes = []apiRoute{
{Method: "POST", Exact: "/mail", Auth: authCustomer},
{Method: "POST", Exact: "/notify", Auth: authCustomer},
{Method: "POST", Exact: "/preferences", Auth: authCustomer},
{Method: "GET", Exact: "/preferences", Auth: authCustomer}, // R-1199 reopened: a box reads its household's list
{Method: "GET", Exact: "/customers", Auth: authGlobal},
{Method: "GET", Prefix: "/customers/", Auth: authGlobal},
{Method: "GET", Prefix: "/recovery/", Auth: authPassphrase},
+17
View File
@@ -0,0 +1,17 @@
{
"rule": "R-1216 / D28 = A (operator 2026-10-22): short when host swap is 0, or below 90 % of min(guests' swap, 8192 MB). 8192 MB is the most Proxmox's installer gives (Install.pm compute_swapsize: RAM, at least 4096 on a disk >= 64 GB, at most 8192). This file is the CONTRACT: the hub (felhom.eu hub/internal/api/testdata/) and the controller (felhom-controller controller/internal/web/testdata/) carry byte-identical copies, checked by felhom.eu scripts/host_swap_rule_gate.py.",
"cases": [
{"name": "8 GB machine from our installer, 4 GB guest (the soak box)", "host_swap_mb": 7871, "guest_swap_mb": 4096, "short": false},
{"name": "32 GB machine, 8 GB swap, 24 GB guest", "host_swap_mb": 8192, "guest_swap_mb": 24576, "short": false},
{"name": "demo-hp as measured 2026-10-22", "host_swap_mb": 8191, "guest_swap_mb": 25898, "short": false},
{"name": "demo-felhom as measured 2026-10-22", "host_swap_mb": 8191, "guest_swap_mb": 12288, "short": false},
{"name": "Tester 1 as measured 2026-10-22", "host_swap_mb": 7871, "guest_swap_mb": 4096, "short": false},
{"name": "any host with no swap", "host_swap_mb": 0, "guest_swap_mb": 4096, "short": true},
{"name": "a host with no swap and no guest", "host_swap_mb": 0, "guest_swap_mb": 0, "short": true},
{"name": "BYO host, 1 GB swap, 4 GB guest", "host_swap_mb": 1024, "guest_swap_mb": 4096, "short": true},
{"name": "the no-swap test 2026-10-22 (swap almost all off)", "host_swap_mb": 938, "guest_swap_mb": 4096, "short": true},
{"name": "boundary: exactly 90 % of 8192", "host_swap_mb": 7373, "guest_swap_mb": 30000, "short": false},
{"name": "boundary: just under 90 % of 8192", "host_swap_mb": 7372, "guest_swap_mb": 30000, "short": true},
{"name": "small guest, host swap equal to it", "host_swap_mb": 2048, "guest_swap_mb": 2048, "short": false}
]
}
+59
View File
@@ -0,0 +1,59 @@
package store
import "fmt"
// R-1199 reopened (2026-10-22). Controller 0.338.0 made a box with no locally saved notification list push its
// DEFAULT list at every start, so the hub mails by what the box's page shows. On a REBUILT box that overwrote the list
// the household had saved on its old box — measured the same morning: demo-felhom's own 13-event list, written by an
// earlier box, became the 17-event default at 07:54:55 UTC. The hub could not refuse it, because nothing in the row
// said who wrote it: the claim seeds a row (SeedNotificationPrefs), a box push upserts it (SaveNotificationPrefs), and
// a one-time migration adds event types (SeedEventTypesOnce).
//
// household_saved is that mark. It is set ONLY by a push the box declares as the household's own save (the settings
// page, or the re-push of a list saved on the box) and is never cleared; a default push never overwrites a row that
// carries it (api.handleSavePreferences). Pinned by TestR1199Reopened_* (api) and TestHouseholdSaved_* (store).
// householdSavedBackfillKey guards the one-time backfill of existing rows.
const householdSavedBackfillKey = "r1199_household_saved_backfill"
// migrateHouseholdSaved adds the column and, once, marks every existing row a box ever wrote. The rule: a row whose
// updated_at differs from its created_at was written after its creation — by a box push (a seed is an insert and
// never updates). Read on the live hub 2026-10-22 before the change: Tester-2 and Tester4 (seeded, never pushed) →
// false; demo-felhom, demo-hp, tester-1 and _resend-rotation-test (pushed) → true. Each row's verdict is logged.
// (SeedEventTypesOnce also bumps updated_at; on 2026-10-22 every row it touched had been pushed, so the rule holds for
// the rows that exist. A row created after this backfill is never judged by it.)
func (s *Store) migrateHouseholdSaved() error {
s.db.Exec(`ALTER TABLE customer_notifications ADD COLUMN household_saved INTEGER NOT NULL DEFAULT 0`)
var done string
if err := s.db.QueryRow(`SELECT value FROM hub_settings WHERE key = ?`, householdSavedBackfillKey).Scan(&done); err == nil && done != "" {
return nil
}
rows, err := s.db.Query(`SELECT customer_id, COALESCE(created_at, ''), COALESCE(updated_at, '') FROM customer_notifications`)
if err != nil {
return err
}
type r struct{ id, created, updated string }
var all []r
for rows.Next() {
var x r
if err := rows.Scan(&x.id, &x.created, &x.updated); err != nil {
rows.Close()
return err
}
all = append(all, x)
}
rows.Close()
for _, x := range all {
saved := x.created != "" && x.updated != "" && x.created != x.updated
if saved {
if _, err := s.db.Exec(`UPDATE customer_notifications SET household_saved = 1 WHERE customer_id = ?`, x.id); err != nil {
return fmt.Errorf("mark %s: %w", x.id, err)
}
}
if s.logger != nil {
s.logger.Printf("[INFO] R-1199 backfill: notification list of %s household_saved=%t (created %s, updated %s)", x.id, saved, x.created, x.updated)
}
}
_, err = s.db.Exec(`INSERT INTO hub_settings (key, value) VALUES (?, datetime('now')) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, householdSavedBackfillKey)
return err
}
@@ -0,0 +1,36 @@
package store
import "testing"
// R-1199 reopened: the one-time backfill marks a row a box wrote (updated_at ≠ created_at) and leaves a seeded row
// unmarked; it runs once (a later push marked by the handler is not re-judged); SaveNotificationPrefs never clears
// the mark. The live hub's six rows, read 2026-10-22, are the shapes below.
// RED-PROOF: with the backfill's `saved` forced false, the first assertion fails — seen failing 2026-10-22.
func TestHouseholdSaved_BackfillMarksPushedRowsOnce(t *testing.T) {
s := newTestStore(t)
// A seeded row (Tester-2's shape) and a pushed row (demo-felhom's shape), written as the old hub wrote them.
s.db.Exec(`INSERT INTO customer_notifications (customer_id, email, enabled_events, created_at, updated_at, household_saved)
VALUES ('seeded', 'a@example.hu', '["node_down"]', '2026-10-04 16:30:00', '2026-10-04 16:30:00', 0),
('pushed', 'b@example.hu', '["disk_warning"]', '2026-02-16 19:22:40', '2026-09-24 09:39:35', 0)`)
s.db.Exec(`DELETE FROM hub_settings WHERE key = ?`, householdSavedBackfillKey)
if err := s.migrateHouseholdSaved(); err != nil {
t.Fatal(err)
}
if p, _ := s.GetNotificationPrefs("pushed"); p == nil || !p.HouseholdSaved {
t.Fatalf("a row a box wrote must be marked household_saved: %+v", p)
}
if p, _ := s.GetNotificationPrefs("seeded"); p == nil || p.HouseholdSaved {
t.Fatalf("a seeded row must not be marked: %+v", p)
}
// Runs once: a row changed after the backfill is not re-judged by it.
s.db.Exec(`UPDATE customer_notifications SET updated_at = '2026-10-23 00:00:00' WHERE customer_id = 'seeded'`)
s.migrateHouseholdSaved()
if p, _ := s.GetNotificationPrefs("seeded"); p.HouseholdSaved {
t.Fatal("the backfill ran a second time")
}
// A plain save never clears the mark.
s.SaveNotificationPrefs("pushed", "b@example.hu", []string{"node_down"}, 6)
if p, _ := s.GetNotificationPrefs("pushed"); !p.HouseholdSaved {
t.Fatal("SaveNotificationPrefs cleared the household mark")
}
}
+31 -11
View File
@@ -893,6 +893,10 @@ func (s *Store) migrate() error {
if err := s.migrateAPITokens(); err != nil {
return fmt.Errorf("api_tokens: %w", err)
}
// R-1199 reopened (2026-10-22): which notification lists a household saved (household_prefs.go).
if err := s.migrateHouseholdSaved(); err != nil {
return fmt.Errorf("household_saved: %w", err)
}
return nil
}
@@ -966,16 +970,19 @@ type NotificationPrefs struct {
Email string
EnabledEvents []string
CooldownHours int
// HouseholdSaved (R-1199 reopened): the household saved this list on a box (household_prefs.go). False for a row
// the claim seeded or a box's default wrote.
HouseholdSaved bool
}
// GetNotificationPrefs returns notification preferences for a customer.
func (s *Store) GetNotificationPrefs(customerID string) (*NotificationPrefs, error) {
var email, eventsJSON string
var cooldownHours int
var cooldownHours, householdSaved int
err := s.db.QueryRow(
"SELECT email, enabled_events, COALESCE(cooldown_hours, 6) FROM customer_notifications WHERE customer_id = ?",
"SELECT email, enabled_events, COALESCE(cooldown_hours, 6), COALESCE(household_saved, 0) FROM customer_notifications WHERE customer_id = ?",
customerID,
).Scan(&email, &eventsJSON, &cooldownHours)
).Scan(&email, &eventsJSON, &cooldownHours, &householdSaved)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
@@ -993,28 +1000,41 @@ func (s *Store) GetNotificationPrefs(customerID string) (*NotificationPrefs, err
}
return &NotificationPrefs{
CustomerID: customerID,
Email: email,
EnabledEvents: events,
CooldownHours: cooldownHours,
CustomerID: customerID,
Email: email,
EnabledEvents: events,
CooldownHours: cooldownHours,
HouseholdSaved: householdSaved == 1,
}, nil
}
// SaveNotificationPrefs creates or updates notification preferences for a customer.
// SaveNotificationPrefs creates or updates notification preferences for a customer. It never changes the
// household_saved mark (SaveNotificationPrefsAs does).
func (s *Store) SaveNotificationPrefs(customerID, email string, enabledEvents []string, cooldownHours int) error {
return s.SaveNotificationPrefsAs(customerID, email, enabledEvents, cooldownHours, false)
}
// SaveNotificationPrefsAs is SaveNotificationPrefs that also SETS the household_saved mark when householdSaved is
// true. The mark is never cleared here: a list the household once saved stays the household's (R-1199 reopened).
func (s *Store) SaveNotificationPrefsAs(customerID, email string, enabledEvents []string, cooldownHours int, householdSaved bool) error {
eventsJSON, _ := json.Marshal(enabledEvents)
if cooldownHours <= 0 {
cooldownHours = 6
}
hs := 0
if householdSaved {
hs = 1
}
_, err := s.db.Exec(`
INSERT INTO customer_notifications (customer_id, email, enabled_events, cooldown_hours, updated_at)
VALUES (?, ?, ?, ?, datetime('now'))
INSERT INTO customer_notifications (customer_id, email, enabled_events, cooldown_hours, household_saved, updated_at)
VALUES (?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(customer_id) DO UPDATE SET
email = excluded.email,
enabled_events = excluded.enabled_events,
cooldown_hours = excluded.cooldown_hours,
household_saved = MAX(customer_notifications.household_saved, excluded.household_saved),
updated_at = datetime('now')`,
customerID, email, string(eventsJSON), cooldownHours,
customerID, email, string(eventsJSON), cooldownHours, hs,
)
return err
}
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""host_swap_rule_gate.py — the hub and the box judge „too little host swap" by ONE rule (R-1216, D28 = A).
Run from the repo root: python3 scripts/host_swap_rule_gate.py
Exit 0 clean · 1 convicted · 2 inconclusive (the felhom-controller clone is not beside this one — never a silent pass).
WHY. The rule lives twice: the hub raises `host_swap_short` for the operator (hub/internal/api/host_swap.go), and the
controller's install page tells the household „A gépen nincs elég cserehely (swap)…" (internal/web/
system_memory_handlers.go). Two Go modules cannot share a function, so they share a CASE TABLE: each repo carries
`testdata/host_swap_rule_cases.json` and a Go test that runs its own implementation over every row. This gate is the
contract between them:
1. the two tables are BYTE-IDENTICAL (a row changed on one side only is drift);
2. each table is READ by a Go test in its own package (a table no test opens proves nothing — the label without the
fact: the file name present, the run absent).
The decoys are in scripts/test_host_swap_rule_gate.py, run from scripts/test_gate_decoys.py.
"""
from __future__ import annotations
import glob
import io
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.environ.get("HOST_SWAP_RULE_REPO") or os.path.dirname(HERE)
WORKSPACE = os.path.dirname(REPO)
NAME = "host_swap_rule_cases.json"
HUB_DIR = os.path.join(REPO, "hub", "internal", "api")
CTRL_DIR = os.path.join(WORKSPACE, "felhom-controller", "controller", "internal", "web")
def read_by_a_test(pkg_dir: str) -> bool:
for f in glob.glob(os.path.join(pkg_dir, "*_test.go")):
if NAME in io.open(f, encoding="utf-8").read():
return True
return False
def main() -> int:
hub = os.path.join(HUB_DIR, "testdata", NAME)
ctrl = os.path.join(CTRL_DIR, "testdata", NAME)
if not os.path.exists(hub):
print("host-swap-rule: CONVICTS — the hub's case table is gone (%s)" % os.path.relpath(hub, REPO))
return 1
if not os.path.isdir(os.path.join(WORKSPACE, "felhom-controller")):
print("host-swap-rule: INCONCLUSIVE — no felhom-controller clone beside this one; the contract cannot be read",
file=sys.stderr)
return 2
problems = []
if not os.path.exists(ctrl):
problems.append("the controller's case table is gone (felhom-controller/controller/internal/web/testdata/%s)" % NAME)
else:
a, b = open(hub, "rb").read(), open(ctrl, "rb").read()
if a != b:
problems.append("the two case tables differ (%d vs %d bytes) — the hub and the box no longer judge host "
"swap by the same rule" % (len(a), len(b)))
for label, d in (("hub", HUB_DIR), ("controller", CTRL_DIR)):
if not read_by_a_test(d):
problems.append("no Go test in the %s package reads %s — a table nothing runs proves nothing" % (label, NAME))
print("host-swap-rule: compared the hub's and the controller's %s, and looked for the test that runs each" % NAME)
if problems:
print("host-swap-rule gate CONVICTS (%d):" % len(problems))
for p in problems:
print(" " + p)
return 1
print("host-swap-rule gate OK: one rule, byte-identical tables, both run by a test.")
return 0
if __name__ == "__main__":
sys.exit(main())
+5
View File
@@ -19,6 +19,7 @@ Gates, in order (all must pass; **non-zero exit on any failure**):
8. wire-contract every emitted field is decodable by its receiver (G-1)
9. hub-copy the hub's customer-facing words, against the retired-name list (R-324)
9b. guide-quote the English volunteer guide, against the controller's English bundle (R-596)
9b. host-swap-rule the hub's and the box's host-swap rule, one byte-identical case table (R-1216)
10. due-checks a dated check in OPEN-ITEMS.md that has come due (R-341)
11. one-register open work living outside OPEN-ITEMS.md (R-369)
12. closed-register a CLOSED row whose verdict still reads open, or an id in both (R-405)
@@ -160,6 +161,10 @@ GATES = [
# Hungarian shipped, and would have quoted it after the fix too. INCONCLUSIVE (exit 2, reported
# not swallowed) when the controller clone is absent. Fast: two file reads.
("guide-quote", os.path.join(SCRIPTS, "guide_quote_gate.py"), [], True, False),
# R-1216 (D28 = A) — the hub's host_swap_short and the controller's install-page swap sentence judge by ONE rule,
# held as a byte-identical case table in both repos, each run by a Go test. INCONCLUSIVE without the controller
# clone. Fast: file reads.
("host-swap-rule", os.path.join(SCRIPTS, "host_swap_rule_gate.py"), [], True, False),
# R-341 — dated checks in the register were prose that nothing read. Fast: stdlib file read.
("due-checks", os.path.join(SCRIPTS, "due_checks_gate.py"), [], True, False),
# R-369 — two files held open work and only one called itself the source of truth, so a READY
+13
View File
@@ -95,6 +95,10 @@ COVERS = {
"script-tests": ("R-885: FIVE cases in scripts/test_script_tests_gate.py, run from here: a suite that PRINTS "
"OK and exits 1 (label without fact), a failing suite three levels deep (scope is a walk), "
"an empty scripts/ tree (checked nothing), the genuine article (must pass), the nested mark"),
"host-swap-rule": ("R-1216: FOUR cases in scripts/test_host_swap_rule_gate.py, run from here: one verdict "
"flipped on the controller side only (every case NAME still matches — name-for-fact), "
"identical tables that no controller test reads any more (the label without the run), "
"no controller clone (must be INCONCLUSIVE), and the genuine article (must pass)"),
"guide-quote": ("R-596: SEVEN cases in scripts/test_guide_quote_gate.py, run from here so "
"this suite stays the single entry point. The load-bearing one is "
"name-for-fact: a guide that lists every key in a table and quotes none of "
@@ -505,6 +509,15 @@ else:
_n = _gq.stdout.strip().splitlines()[-1] if _gq.stdout.strip() else "?"
print(" ok %-20s %s" % ("guide-quote", _n))
# ── host-swap-rule (R-1216) ── built fake workspaces, guide-quote's shape: its own file, run from here.
ran += 1
_hs = subprocess.run([sys.executable, os.path.join("scripts", "test_host_swap_rule_gate.py")],
cwd=ROOT, capture_output=True, text=True)
if _hs.returncode != 0:
fails.append("host-swap-rule: its decoy suite FAILED — a decoy did not convict\n%s" % (_hs.stdout + _hs.stderr)[-800:])
else:
print(" ok %-20s %s" % ("host-swap-rule", (_hs.stdout.strip().splitlines() or ["?"])[-1]))
# ── iso-bootstrap (R-502) ────────────────────────────────────────────────────────────────────────
#
# Its decoys need a fake docker on a one-directory PATH, so they live in their own file and are RUN from
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Decoys for host_swap_rule_gate (R-421). Run: python3 scripts/test_host_swap_rule_gate.py
Each case builds a fake workspace (felhom.eu + a felhom-controller clone) and runs the real gate against it:
1. one row's verdict flipped on the controller side only — drift (the files share every NAME; the fact is a value);
2. identical tables, but no controller test reads the table any more — the label without the fact;
3. no controller clone — INCONCLUSIVE (2), never a pass;
4. the genuine article — must pass.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(HERE)
GATE = os.path.join(HERE, "host_swap_rule_gate.py")
REAL = os.path.join(REPO, "hub", "internal", "api", "testdata", "host_swap_rule_cases.json")
NAME = "host_swap_rule_cases.json"
def build(tmp, ctrl_table=None, ctrl_test=True, with_ctrl=True):
repo = os.path.join(tmp, "felhom.eu")
hub = os.path.join(repo, "hub", "internal", "api")
os.makedirs(os.path.join(hub, "testdata"))
shutil.copy(REAL, os.path.join(hub, "testdata", NAME))
open(os.path.join(hub, "x_test.go"), "w").write('package api\n// os.ReadFile("testdata/%s")\n' % NAME)
if with_ctrl:
web = os.path.join(tmp, "felhom-controller", "controller", "internal", "web")
os.makedirs(os.path.join(web, "testdata"))
data = open(REAL, encoding="utf-8").read() if ctrl_table is None else ctrl_table
open(os.path.join(web, "testdata", NAME), "w", encoding="utf-8").write(data)
body = 'package web\n// os.ReadFile("testdata/%s")\n' % NAME if ctrl_test else "package web\n"
open(os.path.join(web, "y_test.go"), "w").write(body)
return repo
def run(repo):
env = dict(os.environ, HOST_SWAP_RULE_REPO=repo)
return subprocess.run([sys.executable, GATE], env=env, capture_output=True, text=True).returncode
def main():
real = open(REAL, encoding="utf-8").read()
flipped = real.replace('"guest_swap_mb": 12288, "short": false', '"guest_swap_mb": 12288, "short": true', 1)
assert flipped != real, "the decoy's row is gone from the real table"
cases = [
("one verdict flipped on the controller side", dict(ctrl_table=flipped), 1),
("no controller test reads the table", dict(ctrl_test=False), 1),
("no controller clone", dict(with_ctrl=False), 2),
("the genuine article", dict(), 0),
]
fails = 0
for label, kw, want in cases:
tmp = tempfile.mkdtemp()
try:
got = run(build(tmp, **kw))
finally:
shutil.rmtree(tmp)
ok = got == want
fails += 0 if ok else 1
print(" %s %-45s rc=%d want %d" % ("ok" if ok else "FAIL", label, got, want))
print("host-swap-rule decoys: %d/%d" % (len(cases) - fails, len(cases)))
return 1 if fails else 0
if __name__ == "__main__":
sys.exit(main())