Hub v0.149.0: a box the household switched off is not an outage for 14 days (R-998)
gates / gates (push) Successful in 6m40s

hosts.deliberate_off_at follows the agent's deliberate_off field (set, then cleared by the first
report of a new boot; one info event each way). While held, every silence alarm is held: host and
node staleness, the 05:00 backup deadlines, off-site and restore-test freshness, the OS alarms and
the kernel notice. After 14 days the usual alarms fire once, naming the power-off. Pages show
KIKAPCSOLVA and the time. Consequence tests with controls; each guard red-proofed.
This commit is contained in:
2026-10-13 19:47:02 +02:00
parent ed59bf3cbd
commit 50e47e29af
24 changed files with 638 additions and 17 deletions
+68
View File
@@ -0,0 +1,68 @@
package api
import (
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// Event types of the household power-off (R-998, doc 14). Both are info: recorded on the customer's event list,
// never mailed (the dispatcher mails warning and above).
const (
eventHostDeliberateOff = "host_deliberate_off"
eventHostDeliberateOffEnded = "host_deliberate_off_ended"
)
// checkDeliberateOff keeps the host row's deliberate-off time in step with the report: set while the report
// carries the field, cleared by the first report without it (the box is in a new boot — switched back on).
func (h *Handler) checkDeliberateOff(hostID, custID string, rep *hostReportPayload) {
var at *time.Time
if rep.DeliberateOff != nil && rep.DeliberateOff.RequestedAt != "" {
t, err := time.Parse(time.RFC3339, rep.DeliberateOff.RequestedAt)
if err != nil {
h.logger.Printf("[WARN] host-report from %s: unreadable deliberate_off.requested_at %q — ignored", hostID, rep.DeliberateOff.RequestedAt)
return
}
t = t.UTC()
at = &t
}
prev, err := h.store.SetHostDeliberateOff(hostID, at)
if err != nil {
h.logger.Printf("[WARN] host-report from %s: deliberate-off not stored: %v", hostID, err)
return
}
switch {
case at != nil && (prev == nil || !prev.Equal(*at)):
msg := "Switched off by the household at " + at.Format("2006-01-02 15:04 UTC") +
" — silence alarms held for " + holdDays() + " days"
h.logger.Printf("[INFO] host %s: deliberate power-off recorded (%s)", hostID, at.Format(time.RFC3339))
h.saveInfoEvent(custID, eventHostDeliberateOff, msg)
case at == nil && prev != nil:
msg := "Reporting again after the household's power-off of " + prev.Format("2006-01-02 15:04 UTC")
h.logger.Printf("[INFO] host %s: deliberate power-off ended (was %s)", hostID, prev.Format(time.RFC3339))
h.saveInfoEvent(custID, eventHostDeliberateOffEnded, msg)
}
}
func holdDays() string {
d := int(store.DeliberateOffHold / (24 * time.Hour))
return itoa(d)
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b []byte
for n > 0 {
b = append([]byte{byte('0' + n%10)}, b...)
n /= 10
}
return string(b)
}
func (h *Handler) saveInfoEvent(custID, eventType, msg string) {
if _, err := h.store.SaveEvent(custID, eventType, "info", msg, "{}", "hub"); err != nil {
h.logger.Printf("[WARN] save %s event: %v", eventType, err)
}
}
+44
View File
@@ -0,0 +1,44 @@
package api
import (
"net/http"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// R-998: the agent's deliberate_off field sets the host row's hold; the next report without it (a new boot — the box
// was switched back on) clears it. Each change leaves one info event on the customer's list; a repeat leaves none.
func TestHostReport_DeliberateOffSetThenCleared(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"})
withOff := strings.TrimSuffix(validReportBody("h1"), "}") + `,"deliberate_off":{"requested_at":"2026-10-13T18:00:00Z"}}`
for i := 0; i < 2; i++ { // the field rides every report until the box is down; the second must not re-record
if rr := do(h, http.MethodPost, "/host-report", "HKEY", withOff); rr.Code != 200 {
t.Fatalf("report %d: %d %s", i, rr.Code, rr.Body.String())
}
}
at := st.HostDeliberateOffAt("h1")
if at == nil || !at.Equal(time.Date(2026, 10, 13, 18, 0, 0, 0, time.UTC)) {
t.Fatalf("hold = %v, want 2026-10-13 18:00 UTC", at)
}
evs, _ := st.GetEventsByType("c1", eventHostDeliberateOff, time.Now().Add(-time.Hour))
if len(evs) != 1 || evs[0].Severity != "info" {
t.Fatalf("set events = %+v, want exactly one info event", evs)
}
if rr := do(h, http.MethodPost, "/host-report", "HKEY", validReportBody("h1")); rr.Code != 200 {
t.Fatalf("plain report: %d", rr.Code)
}
if st.HostDeliberateOffAt("h1") != nil {
t.Fatal("a report without the field must clear the hold")
}
ended, _ := st.GetEventsByType("c1", eventHostDeliberateOffEnded, time.Now().Add(-time.Hour))
if len(ended) != 1 {
t.Fatalf("ended events = %d, want 1", len(ended))
}
}
+6
View File
@@ -744,6 +744,11 @@ type hostReportPayload struct {
CollectedAt time.Time `json:"collected_at"`
Lines []string `json:"lines"`
} `json:"log_tail"`
// DeliberateOff (R-998, agent >= 0.155.0): the household asked this box to power off, in its current boot.
// Absent = not (or an older agent). deliberate_off.go turns it into the host row's hold.
DeliberateOff *struct {
RequestedAt string `json:"requested_at"`
} `json:"deliberate_off"`
}
// hostForeignKeyArchives mirrors the agent's hub.ForeignKeyArchives (R-366 slice 2).
@@ -921,6 +926,7 @@ func (h *Handler) handleHostReport(w http.ResponseWriter, r *http.Request) {
h.checkTunnel(hostID, custID, rep.Cloudflared.Status, rep.Cloudflared.Detail)
h.checkCrash(hostID, custID, body)
h.checkDeliberateOff(hostID, custID, &rep)
for _, g := range rep.Guests {
status := g.Status