v0.342.0 (unreleased): the upload and CPU watch — an app that sends or burns far more than its own numbers explain is one operator-only alarm; the line learnt from the off-site push; qBittorrent's default upload limit (never over the household's)
gates / gates (push) Successful in 1m17s

internal/upwatch: exact per-container counters from the Docker Engine API, per-app judging (upload >= max(5 Mbit/s,
25% of the line) for 15 min; CPU >= max(150%, 50% x cores) for 30 min), explainers qBittorrent / Transmission / Plex,
app_unexplained_upload / app_unexplained_cpu + _cleared (hub 0.169.0). The off-site push is sampled once a second
for the line (a lower bound). qBittorrent limit = 50% of the line, the household's limit always wins. 20 red-proofs.
Spike + design: felhom.eu audits/SPIKE-upload-watch-2026-10-23.md, architecture/08 section 9. Not released.
This commit is contained in:
2026-10-23 21:26:09 +02:00
parent 277e99839d
commit a9c1997fbc
35 changed files with 2438 additions and 0 deletions
+27
View File
@@ -78,6 +78,11 @@ type Settings struct {
// AppUpdateEventsSeeded (v0.264.0) guards the ONE-TIME add of app_update_undone / app_update_held
// into an existing household's stored prefs (seedAppUpdateEvents).
AppUpdateEventsSeeded bool `json:"app_update_events_seeded,omitempty"`
// Upwatch (the upload and CPU watch, internal/upwatch) keeps its own state here as one opaque JSON block: the
// measured upload line (the last 14 off-site pushes) and qBittorrent's default-limit record (who owns the limit).
// Opaque on purpose — this package does not know the watch's types, and the watch does not reach into settings.
Upwatch json.RawMessage `json:"upwatch,omitempty"`
// UnhealthyStopEventSeeded (v0.269.0) guards the ONE-TIME add of app_stopped_unhealthy (the
// seedAppUpdateEvents shape).
UnhealthyStopEventSeeded bool `json:"unhealthy_stop_event_seeded,omitempty"`
@@ -900,6 +905,28 @@ func (s *Settings) migrateResticToRsync() {
// Save writes settings to disk atomically (write to .tmp, rename).
// Caller must hold the write lock or call this from a method that does.
// GetUpwatch returns a copy of the watch's state block (nil when none was ever written).
func (s *Settings) GetUpwatch() json.RawMessage {
s.mu.RLock()
defer s.mu.RUnlock()
if s.Upwatch == nil {
return nil
}
return append(json.RawMessage(nil), s.Upwatch...)
}
// UpdateUpwatch replaces the watch's state block through fn (read-modify-write under the lock) and saves.
func (s *Settings) UpdateUpwatch(fn func(old json.RawMessage) (json.RawMessage, error)) error {
s.mu.Lock()
defer s.mu.Unlock()
nv, err := fn(append(json.RawMessage(nil), s.Upwatch...))
if err != nil {
return err
}
s.Upwatch = nv
return s.save()
}
func (s *Settings) save() error {
data, err := json.MarshalIndent(s, "", " ")
if err != nil {