hub(R-1299): the window grant/cancel event is really stored (0.174.0 wrote no row); the block moves to the Backup & DR tab
gates / gates (push) Successful in 1m59s

This commit is contained in:
admin committed 2026-10-27 20:12:43 +01:00
1 parent 1796112508
commit de57c090bf
4 files changed
+113 -67

No files matched your search

+11
View File
@@ -1,3 +1,14 @@
## Unreleased (next: v0.174.1) — R-1299: the window event is really recorded; the block moves to the Backup & DR tab
- **Defect in v0.174.0, found by the operator's live click on tester-1 (2026-10-27 20:07 CET):** a grant logged its line
but wrote NO event row — `emitWindowEvent` called only the dispatcher, which routes mail and writes no row (an info
event routes nowhere). It now writes the row with `store.SaveEvent` (as `offsitekeys.Service.event` does), then
hands it to the dispatcher. The tests asserted the dispatcher CALL (mechanism) and passed; they now read the STORED
rows (consequence) — red-proved by putting 0.174.0's dispatcher-only shape back (two tests fail).
- **The operator looked for the button under „Backup & DR" first:** the block is now its own card there („Off-site
clean-up window", only when off-site is enabled and reported); the Overview's Backup card points to it; every
redirect, the confirm's Back and the form land on `#tab=backup`. Pinned by the page test (red with 0.174.0's place).
## v0.174.0 (2026-10-27) — R-1299: the one-time clean-up window gets a button on the customer page — DEPLOYED on the operator's yes (image sha256:4f9ee04a…, from `d85198b8`, CI 2433)
- **R-1299:** the customer page's Backup card, in its off-site part, shows a „Clean-up window" block: the weekly
+11 -5
View File
@@ -129,7 +129,7 @@ func (s *Server) handleOffsiteWindowGrant(w http.ResponseWriter, r *http.Request
page := r.FormValue("from") == "page"
answer := func(code int, flash, msg string) {
if page {
http.Redirect(w, r, "/customers/"+url.PathEscape(cid)+"?flash="+flash+"#offsite-window", http.StatusSeeOther)
http.Redirect(w, r, "/customers/"+url.PathEscape(cid)+"?flash="+flash+"#tab=backup", http.StatusSeeOther)
return
}
if code >= 400 {
@@ -194,10 +194,16 @@ func (s *Server) handleOffsiteWindowGrant(w http.ResponseWriter, r *http.Request
answer(http.StatusOK, "window_granted", "")
}
// emitWindowEvent WRITES the event row (the record the brief asks for) and then hands it to the dispatcher. The
// dispatcher only routes mail — it writes no row, and an info event routes nowhere — so the row is this function's job,
// as it is offsitekeys.Service.event's. Hub 0.174.0 called only the dispatcher and recorded nothing (seen live on
// tester-1 2026-10-27; the test then asserted the call, not the row). Pinned by the TestR1299_* row checks.
func (s *Server) emitWindowEvent(customerID, msg string, details map[string]any) {
if s.emit == nil {
return
}
b, _ := json.Marshal(details)
s.emit(customerID, eventWindowGrant, "info", msg, string(b), "hub")
if _, err := s.store.SaveEvent(customerID, eventWindowGrant, "info", msg, string(b), "hub"); err != nil {
s.logger.Printf("[WARN] offsite window: saving the %s event for %s: %v", eventWindowGrant, customerID, err)
}
if s.emit != nil {
s.emit(customerID, eventWindowGrant, "info", msg, string(b), "hub")
}
}
+32 -8
View File
@@ -44,6 +44,23 @@ func windowRig(t *testing.T, report string) (*Server, *store.Store, http.Handler
return s, st, s.Handler(), &evs
}
// windowRows reads the STORED offsite_window_grant rows — the consequence. Hub 0.174.0's test captured the dispatcher
// call instead, passed, and the live grant on tester-1 wrote no row (the dispatcher writes none).
func windowRows(t *testing.T, st *store.Store) []store.Event {
t.Helper()
all, err := st.GetRecentEvents("acme", 50)
if err != nil {
t.Fatal(err)
}
var out []store.Event
for _, e := range all {
if e.EventType == eventWindowGrant {
out = append(out, e)
}
}
return out
}
func windowPost(h http.Handler, form url.Values, csrf bool) *httptest.ResponseRecorder {
if csrf {
form.Set("_csrf", "tok1")
@@ -74,6 +91,9 @@ func TestR1299_ThePageShowsTheWindowBlockPerBranch(t *testing.T) {
_, st, h, _ := windowRig(t, windowReport)
page := windowPage(t, h, "")
if b, w := strings.Index(page, `data-tab="backup">`+"\n"), strings.Index(page, `id="offsite-window"`); b < 0 || w < b {
t.Errorf("the window block must sit in the Backup & DR tab (panel at %d, block at %d) — where the operator looked first", b, w)
}
for _, want := range []string{`id="offsite-window"`, `id="window-form"`, `name="window" value="confirm"`, "Normal cap per window",
"20 (half the snapshots", "at most 40", "none waiting", "never"} {
if !strings.Contains(page, want) {
@@ -111,15 +131,18 @@ func TestR1299_ThePageShowsTheWindowBlockPerBranch(t *testing.T) {
func TestR1299_AGrantThroughTheFormStoresOneGrantWithItsLimit(t *testing.T) {
_, st, h, evs := windowRig(t, windowReport)
w := windowPost(h, url.Values{"from": {"page"}, "confirm": {"1"}, "max_remove": {"33"}}, true)
if w.Code != http.StatusSeeOther || !strings.Contains(w.Header().Get("Location"), "flash=window_granted") {
if w.Code != http.StatusSeeOther || !strings.HasSuffix(w.Header().Get("Location"), "flash=window_granted#tab=backup") {
t.Fatalf("grant: %d Location=%q", w.Code, w.Header().Get("Location"))
}
if pending, max := st.PendingOffsiteWindowGrant("acme"); !pending || max != 33 {
t.Fatalf("stored grant: pending=%v max=%d, want true 33", pending, max)
}
if len(*evs) != 1 || (*evs)[0].typ != eventWindowGrant || (*evs)[0].sev != "info" ||
!strings.Contains((*evs)[0].details, `"max_remove":33`) || !strings.Contains((*evs)[0].details, `"by":"operator session"`) {
t.Fatalf("events: %+v — want one info %s naming the limit and who", *evs, eventWindowGrant)
if rows := windowRows(t, st); len(rows) != 1 || rows[0].Severity != "info" ||
!strings.Contains(rows[0].DetailsJSON, `"max_remove":33`) || !strings.Contains(rows[0].DetailsJSON, `"by":"operator session"`) {
t.Fatalf("stored rows: %+v — want ONE info %s row naming the limit and who", rows, eventWindowGrant)
}
if len(*evs) != 1 {
t.Fatalf("dispatcher calls: %d, want 1", len(*evs))
}
if granted, max := st.TakeOffsiteWindowGrant("acme"); !granted || max != 33 {
t.Fatalf("the window took granted=%v max=%d", granted, max)
@@ -168,8 +191,8 @@ func TestR1299_RefusalsStoreNothing(t *testing.T) {
if pending, _ := st.PendingOffsiteWindowGrant("acme"); pending {
t.Errorf("%s: a grant was stored", c.name)
}
if len(*evs) != 0 {
t.Errorf("%s: %d event(s) for a refused request", c.name, len(*evs))
if len(*evs) != 0 || len(windowRows(t, st)) != 0 {
t.Errorf("%s: an event for a refused request", c.name)
}
}
}
@@ -208,7 +231,8 @@ func TestR1299_CancelRemovesAWaitingGrant(t *testing.T) {
if granted, _ := st.TakeOffsiteWindowGrant("acme"); granted {
t.Fatal("the box's window would still use the cancelled grant")
}
if len(*evs) != 1 || !strings.Contains((*evs)[0].details, `"cancelled":true`) {
t.Fatalf("events: %+v — want one cancel event", *evs)
if rows := windowRows(t, st); len(rows) != 1 || !strings.Contains(rows[0].DetailsJSON, `"cancelled":true`) {
t.Fatalf("stored rows: %+v — want ONE cancel row", rows)
}
_ = evs
}
@@ -344,61 +344,8 @@
claim the repository is empty and that is not what is known. They fill in after the next
off-site run reads the repository.</p>
{{end}}
<p class="text-muted">The one-time clean-up window is on the <a href="#tab=backup">Backup &amp; DR</a> tab.</p>
<!-- R-1299: the one-shot clean-up window. Same route and rule as the script address
(POST /offsite/window-grant/<customer>, offsite:write); the confirm step is a GET that stores nothing. -->
{{with $.WindowCard}}
<h3 id="offsite-window">Clean-up window</h3>
<div class="info-grid">
<div class="info-item">
<span class="label">Weekly windows</span>
<span class="value">{{if .WeeklyOn}}On{{else}}Off (fleet-wide){{end}}</span>
</div>
<div class="info-item">
<span class="label">Normal cap per window</span>
<span class="value">{{if .CapKnown}}{{.NormalCap}} (half the snapshots, at least 5){{else}}&mdash; (half the snapshots; count not measured){{end}}</span>
</div>
<div class="info-item">
<span class="label">One-time window</span>
<span class="value">{{if .Pending}}<strong>waiting</strong> &mdash; {{if .PendingMax}}limit {{.PendingMax}}{{else}}the normal cap{{end}}{{else}}none waiting{{end}}</span>
</div>
<div class="info-item">
<span class="label">Last window</span>
<span class="value">{{with .Last}}#{{.ID}} {{.OpenedAt.UTC.Format "2006-01-02 15:04"}} UTC &mdash; {{if .ClosedAt.IsZero}}open{{else}}{{.BoxResult}}, {{.CountBefore}} &rarr; {{.CountAfter}}{{end}}{{if .MaxRemove}} (cap {{.MaxRemove}}){{end}}{{else}}never{{end}}</span>
</div>
</div>
{{if .Pending}}
<form method="POST" action="/offsite/window-grant/{{$.CustomerID}}" class="inline-confirm">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="from" value="page">
<input type="hidden" name="cancel" value="1">
<button type="submit" class="btn btn-outline btn-sm">Cancel the waiting window</button>
</form>
{{else if .Confirm}}
<div class="flash flash-warn" id="window-confirm">
Open <strong>one</strong> clean-up window for <strong>{{$.CustomerID}}</strong> with
<strong>{{if .ConfirmLimit}}a limit of {{.ConfirmLimit}}{{else}}the normal cap{{end}}</strong>?
The box uses it on its next off-site run; it removes only what its keep rule selects, and the
fake-snapshot guard still runs. One use, then it is gone.
<form method="POST" action="/offsite/window-grant/{{$.CustomerID}}" class="inline-confirm">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="from" value="page">
<input type="hidden" name="confirm" value="1">
<input type="hidden" name="max_remove" value="{{.ConfirmLimit}}">
<button type="submit" class="btn btn-danger btn-sm">Open the window</button>
<a href="/customers/{{$.CustomerID}}#offsite-window" class="btn btn-outline btn-sm">Back</a>
</form>
</div>
{{else}}
{{if .LimitError}}<p class="flash flash-error">Not granted: {{.LimitError}}.</p>{{end}}
<form method="GET" action="/customers/{{$.CustomerID}}" class="inline-confirm" id="window-form">
<input type="hidden" name="window" value="confirm">
<label for="window_max">Limit for this window (empty = the normal cap; at most {{.Bound}})</label>
<input type="number" id="window_max" name="window_max" min="1" max="{{.Bound}}">
<button type="submit" class="btn btn-outline btn-sm">Open one clean-up window&hellip;</button>
</form>
{{end}}
{{end}}
{{end}}
{{end}}
</section>
@@ -1091,6 +1038,64 @@
<div class="tab-panel" data-tab="backup">
{{if .HasReports}}
{{if and .BackupCard.OffsiteReported .BackupCard.OffsiteEnabled}}
<section class="card" id="offsite-window">
<h2>Off-site clean-up window</h2>
<!-- R-1299: the one-shot clean-up window. Same route and rule as the script address
(POST /offsite/window-grant/<customer>, offsite:write); the confirm step is a GET that stores nothing. -->
{{with $.WindowCard}}
<div class="info-grid">
<div class="info-item">
<span class="label">Weekly windows</span>
<span class="value">{{if .WeeklyOn}}On{{else}}Off (fleet-wide){{end}}</span>
</div>
<div class="info-item">
<span class="label">Normal cap per window</span>
<span class="value">{{if .CapKnown}}{{.NormalCap}} (half the snapshots, at least 5){{else}}&mdash; (half the snapshots; count not measured){{end}}</span>
</div>
<div class="info-item">
<span class="label">One-time window</span>
<span class="value">{{if .Pending}}<strong>waiting</strong> &mdash; {{if .PendingMax}}limit {{.PendingMax}}{{else}}the normal cap{{end}}{{else}}none waiting{{end}}</span>
</div>
<div class="info-item">
<span class="label">Last window</span>
<span class="value">{{with .Last}}#{{.ID}} {{.OpenedAt.UTC.Format "2006-01-02 15:04"}} UTC &mdash; {{if .ClosedAt.IsZero}}open{{else}}{{.BoxResult}}, {{.CountBefore}} &rarr; {{.CountAfter}}{{end}}{{if .MaxRemove}} (cap {{.MaxRemove}}){{end}}{{else}}never{{end}}</span>
</div>
</div>
{{if .Pending}}
<form method="POST" action="/offsite/window-grant/{{$.CustomerID}}" class="inline-confirm">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="from" value="page">
<input type="hidden" name="cancel" value="1">
<button type="submit" class="btn btn-outline btn-sm">Cancel the waiting window</button>
</form>
{{else if .Confirm}}
<div class="flash flash-warn" id="window-confirm">
Open <strong>one</strong> clean-up window for <strong>{{$.CustomerID}}</strong> with
<strong>{{if .ConfirmLimit}}a limit of {{.ConfirmLimit}}{{else}}the normal cap{{end}}</strong>?
The box uses it on its next off-site run; it removes only what its keep rule selects, and the
fake-snapshot guard still runs. One use, then it is gone.
<form method="POST" action="/offsite/window-grant/{{$.CustomerID}}" class="inline-confirm">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="from" value="page">
<input type="hidden" name="confirm" value="1">
<input type="hidden" name="max_remove" value="{{.ConfirmLimit}}">
<button type="submit" class="btn btn-danger btn-sm">Open the window</button>
<a href="/customers/{{$.CustomerID}}#tab=backup" class="btn btn-outline btn-sm">Back</a>
</form>
</div>
{{else}}
{{if .LimitError}}<p class="flash flash-error">Not granted: {{.LimitError}}.</p>{{end}}
<form method="GET" action="/customers/{{$.CustomerID}}#tab=backup" class="inline-confirm" id="window-form">
<input type="hidden" name="window" value="confirm">
<label for="window_max">Limit for this window (empty = the normal cap; at most {{.Bound}})</label>
<input type="number" id="window_max" name="window_max" min="1" max="{{.Bound}}">
<button type="submit" class="btn btn-outline btn-sm">Open one clean-up window&hellip;</button>
</form>
{{end}}
{{end}}
</section>
{{end}}
{{if .HasDRRecipe}}
<!-- DR recipe (secret-free reconstruction recipe) -->
<section class="card">