hub v0.126.0: fresh connect link from the old one (R-719); day-one mails (R-723); bind-page wording (R-725); volunteer guide current (R-722); ep0 cleanup and release evidence
gates / gates (push) Successful in 29s

Red-proofs RP40-RP42.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-30 10:35:17 +02:00
parent 27641ce9e7
commit 80aeac71f6
21 changed files with 562 additions and 40 deletions
+27
View File
@@ -531,6 +531,18 @@ func (d *Dispatcher) processOperator(customerID, eventType, severity, message, d
cooldownTierSuffix(detailsJSON) + cooldownRunSuffix(detailsJSON) +
cooldownStackSuffix(eventType, detailsJSON) + cooldownStorageSuffix(eventType, detailsJSON)
window := operatorCooldownFor(eventType)
// R-723 (v0.126.0): a whole-guest tier skipped for absent storage in a box's FIRST HOUR is provisioning
// still in flight, not a fault (measured 2026-09-29: the first local backup ran 7 min after enrolment,
// the off-site descriptor arrived with the next 15-min host report, the off-site tier then backed up
// fine — and the operator had been mailed „skipped"). Recorded, not mailed.
if eventType == "backup_tier_skipped" && d.hostEnrolledWithin(customerID, firstHourOfABox) {
if err := d.store.LogNotification(customerID, eventType, severity, message,
"suppressed", "first hour of a new box (R-723)", "operator"); err != nil {
d.logger.Printf("[WARN] Failed to record suppressed operator notification for %s/%s: %v", customerID, eventType, err)
}
d.logger.Printf("[INFO] Operator email suppressed for %s/%s — the box enrolled less than %s ago", customerID, eventType, firstHourOfABox)
return
}
d.mu.Lock()
if last, ok := d.opCooldowns[cooldownKey]; ok && time.Since(last) < window {
d.mu.Unlock()
@@ -856,3 +868,18 @@ func (d *Dispatcher) SendSelfBindEmail(customerID, email, link string) error {
d.store.LogNotification(customerID, "selfbind_link", "info", subject, "sent", "", "customer")
return nil
}
// firstHourOfABox is how long after enrolment a whole-guest tier skip is provisioning, not a fault (R-723).
const firstHourOfABox = time.Hour
// hostEnrolledWithin reports whether the customer's current host was enrolled less than d ago.
func (d *Dispatcher) hostEnrolledWithin(customerID string, within time.Duration) bool {
if d.store == nil {
return false
}
h, err := d.store.GetHostByCustomer(customerID)
if err != nil || h == nil || h.CreatedAt.IsZero() {
return false
}
return time.Since(h.CreatedAt) < within
}
@@ -0,0 +1,65 @@
package notify
import (
"database/sql"
"io"
"log"
"path/filepath"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
_ "modernc.org/sqlite"
)
// storeWithHost opens a store with customer c1 and one host enrolled `ago` before now.
func storeWithHost(t *testing.T, ago time.Duration) *store.Store {
t.Helper()
path := filepath.Join(t.TempDir(), "d.db")
st, err := store.New(path, log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "k", RetrievalPassword: "p"})
if err := st.UpsertHost(&store.Host{HostID: "c1-abc123", CustomerID: "c1", APIKey: "hk"}); err != nil {
t.Fatal(err)
}
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`UPDATE hosts SET created_at = ? WHERE host_id = 'c1-abc123'`,
time.Now().UTC().Add(-ago).Format("2006-01-02 15:04:05")); err != nil {
t.Fatal(err)
}
return st
}
const tierSkipMsg = "Whole-guest backup tier felhom-pbs skipped: its storage does not exist on the host (never provisioned or removed). No app was stopped for it."
// R-723 (v0.126.0) — the 2026-09-29 shape: the first whole-guest run fired 7 min after enrolment, before
// the off-site descriptor arrived, and the operator was mailed „skipped". The CONSEQUENCE asserted: no
// operator mail in a box's first hour.
// COMPANION RED-PROOF: drop the first-hour check in processOperator → 1 operator mail.
func TestR723_FirstHourTierSkipIsNotMailed(t *testing.T) {
st := storeWithHost(t, 7*time.Minute)
d := NewDispatcher(st, "test-key", "hub@felhom.eu", "op@felhom.eu", true, log.New(io.Discard, "", 0))
mails := captureSeam(d)
d.ProcessEvent("c1", "backup_tier_skipped", "warning", tierSkipMsg, `{"tier":"felhom-pbs"}`, "controller")
if op := mailsFor(*mails, "op@felhom.eu"); len(op) != 0 {
t.Fatalf("a first-hour tier skip mailed the operator (%d mails)", len(op))
}
}
// Control: the same skip on a box enrolled a week ago is a real provisioning gap and still mails.
func TestR723_TierSkipOnAnOldBoxStillMails(t *testing.T) {
st := storeWithHost(t, 7*24*time.Hour)
d := NewDispatcher(st, "test-key", "hub@felhom.eu", "op@felhom.eu", true, log.New(io.Discard, "", 0))
mails := captureSeam(d)
d.ProcessEvent("c1", "backup_tier_skipped", "warning", tierSkipMsg, `{"tier":"felhom-pbs"}`, "controller")
if op := mailsFor(*mails, "op@felhom.eu"); len(op) != 1 {
t.Fatalf("an old box's tier skip must still mail the operator, got %d", len(op))
}
}