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
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:
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user