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
gates / gates (push) Successful in 15m54s
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}`))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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},
|
||||
|
||||
@@ -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}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user