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
gates / gates (push) Successful in 1m59s
This commit is contained in:
1 parent
1796112508
commit
de57c090bf
4 files changed
+113
-67
No files matched your search
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 & 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}}— (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> — {{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 — {{if .ClosedAt.IsZero}}open{{else}}{{.BoxResult}}, {{.CountBefore}} → {{.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…</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}}— (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> — {{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 — {{if .ClosedAt.IsZero}}open{{else}}{{.BoxResult}}, {{.CountBefore}} → {{.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…</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{if .HasDRRecipe}}
|
||||
<!-- DR recipe (secret-free reconstruction recipe) -->
|
||||
<section class="card">
|
||||
|
||||
Reference in new issue
Block a user