Files
felhom.eu/hub/internal/monitor/golden_vouch.go
T
admin 655f5f66f8
gates / gates (push) Successful in 6m0s
hub v0.147.0 (code): the operator's answers S4, S6, S10, S29, S30, S32, S33
- 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
2026-10-12 14:54:14 +02:00

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")
}