655f5f66f8
gates / gates (push) Successful in 6m0s
- S4 / R-242: GoldenVouchChecker - a published golden unvouched for 2 h raises golden_not_vouched (operator only) - S6 / R-244: the delete cascade de-references app_log_issues; one-time sweep flag -sweep-app-log-residue=count|apply - S10 / R-266: read_failed storage entry shows "read failed", not 0 % - S29 / R-250: host-key scan IPv4 first, ~2 min ladder, "safe to press Save again" error - S30 / R-246: stale_at retired (reader, setter, badge) - S32 / R-521: storage_reconnected ends the storage_disconnected cooldown on both channels - S33 / R-402: host page "Last full check (off-site)" line; two wire-gate allowlist entries removed Each with a test seen failing first. Not built or deployed yet (operator-attended deploy). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
98 lines
4.0 KiB
Go
98 lines
4.0 KiB
Go
package monitor
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"sync"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-hub/internal/semver"
|
|
)
|
|
|
|
// GoldenVouchChecker is R-242's second half (operator ruling 2026-10-12, S4, `09` §3 decision 199):
|
|
// an operator alarm when the newest PUBLISHED golden is not the one the hub VOUCHES.
|
|
//
|
|
// The bake half has a gate (scripts/golden_currency_gate.py), but that gate cannot see the vouch — it
|
|
// lives only in the hub's settings — so a golden baked and uploaded but never vouched passed every check
|
|
// and a fresh install would still land on the older controller. Here the hub compares the two things it
|
|
// can see itself: Gitea's `felhom-golden` package versions and its own manifest's golden_version.
|
|
//
|
|
// It waits a grace period after it first sees the gap, because a bake and its vouch are normally minutes
|
|
// apart in the same session; an alarm then would be noise. Gitea unreachable or an empty list = no
|
|
// verdict (logged), never an alarm and never a clear. One alarm per unvouched version per process.
|
|
// Pinned by TestGoldenVouchChecker.
|
|
type GoldenVouchChecker struct {
|
|
list func(ctx context.Context) ([]string, error) // newest-first published golden versions
|
|
vouched func() string // the hub manifest's golden_version
|
|
onEvent EventNotifyFunc
|
|
logger *log.Logger
|
|
now func() time.Time
|
|
grace time.Duration
|
|
every time.Duration
|
|
|
|
mu sync.Mutex
|
|
lastRun time.Time
|
|
gapSince map[string]time.Time // unvouched newest version → when first seen
|
|
alertedOn string
|
|
}
|
|
|
|
const (
|
|
eventGoldenNotVouched = "golden_not_vouched"
|
|
goldenVouchGrace = 2 * time.Hour
|
|
goldenVouchCheckEvery = 15 * time.Minute
|
|
goldenVouchListDeadline = 20 * time.Second
|
|
)
|
|
|
|
// NewGoldenVouchChecker: list = the Gitea package listing, vouched = the manifest's golden_version.
|
|
func NewGoldenVouchChecker(list func(ctx context.Context) ([]string, error), vouched func() string, onEvent EventNotifyFunc, logger *log.Logger) *GoldenVouchChecker {
|
|
return &GoldenVouchChecker{list: list, vouched: vouched, onEvent: onEvent, logger: logger,
|
|
now: time.Now, grace: goldenVouchGrace, every: goldenVouchCheckEvery, gapSince: map[string]time.Time{}}
|
|
}
|
|
|
|
// Check runs on the hub's 60 s sweep and throttles itself to one Gitea listing per 15 minutes.
|
|
func (gc *GoldenVouchChecker) Check() {
|
|
gc.mu.Lock()
|
|
defer gc.mu.Unlock()
|
|
now := gc.now()
|
|
if !gc.lastRun.IsZero() && now.Sub(gc.lastRun) < gc.every {
|
|
return
|
|
}
|
|
gc.lastRun = now
|
|
ctx, cancel := context.WithTimeout(context.Background(), goldenVouchListDeadline)
|
|
defer cancel()
|
|
vers, err := gc.list(ctx)
|
|
if err != nil || len(vers) == 0 {
|
|
gc.logger.Printf("[WARN] Golden vouch check: no verdict (published list: %d version(s), err=%v)", len(vers), err)
|
|
return
|
|
}
|
|
newest, vouched := vers[0], gc.vouched()
|
|
if vouched != "" && semver.Compare(newest, vouched) <= 0 {
|
|
if gc.alertedOn != "" {
|
|
gc.logger.Printf("[INFO] Golden vouch check: vouched %s is current again (was alerting on %s)", vouched, gc.alertedOn)
|
|
}
|
|
gc.gapSince, gc.alertedOn = map[string]time.Time{}, ""
|
|
gc.logger.Printf("[DEBUG] Golden vouch check: newest published %s, vouched %s — ok", newest, vouched)
|
|
return
|
|
}
|
|
first, seen := gc.gapSince[newest]
|
|
if !seen {
|
|
gc.gapSince[newest] = now
|
|
gc.logger.Printf("[INFO] Golden vouch check: golden %s is published, the hub vouches %q — alarm in %s if still so", newest, vouched, gc.grace)
|
|
return
|
|
}
|
|
if now.Sub(first) < gc.grace || gc.alertedOn == newest {
|
|
return
|
|
}
|
|
gc.alertedOn = newest
|
|
vouchedTxt := vouched
|
|
if vouchedTxt == "" {
|
|
vouchedTxt = "none"
|
|
}
|
|
msg := fmt.Sprintf("Golden %s is published but NOT vouched (the hub vouches %s, for %s now) — a fresh install still gets the older controller. Vouch it under Configuration → Day-0 artifacts, or record why not.",
|
|
newest, vouchedTxt, now.Sub(first).Round(time.Minute))
|
|
gc.logger.Printf("[INFO] Golden vouch check: %s", msg)
|
|
gc.onEvent("", eventGoldenNotVouched, "warning", msg,
|
|
fmt.Sprintf(`{"published":%q,"vouched":%q}`, newest, vouched), "hub")
|
|
}
|