v0.116.0: observability pass — always-on debug ring + leveled sweep + agent tab + self-log pull — MinAgent: 0.81.0

Capture layer: LogBuffer always exists; logger = MultiWriter(LevelFilterWriter
(stdout, logging.level), ring) so DEBUG detail exists remotely without a config
flip while docker logs keep respecting the level. New internal/logx leveled
helpers. Report ACK gains controller_log_requested (additive); next report
ships controller_log_tail (128KB, consume-once, app-tail wire byte-compatible).
Debug page: Vezérlő|Ügynök tabs; agent tab proxies agent /debug/logs with the
pre-0.83 notice on typed 404. Sweep: netstorage_job phases, netprobe, handler
validation refusals + orphan WARN, SupportsWithSource gate line, agentapi
per-call DEBUG, migrate phase lines, tier2/offbox unswallowed persists.
Red-proofs: filter-disabled, drain-removed, dropped-phase-line all FAIL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-11 16:45:57 +02:00
parent 647116480a
commit 26a43708b7
25 changed files with 797 additions and 31 deletions
+4
View File
@@ -168,6 +168,10 @@ func BuildReport(
}, logger)
}
// Controller self-log tail (v0.116.0): the controller's OWN debug ring, same
// consume-once ACK-flag pattern (selftail.go). nil in the steady state.
r.ControllerLogTail = buildControllerLogTail(logger)
// Geo-restriction status — ALWAYS present (even when never configured) so the hub
// always renders the section. A nil pointer (omitempty) made the hub hide the whole
// section for a never-configured controller; a present-but-disabled report renders
+4
View File
@@ -44,6 +44,10 @@ type PushResponse struct {
// pull-based ACK-flag pattern as escrow: the NEXT report ships the tails; the hub
// clears the pending request on receipt (consume-once). Absent/empty = nothing pending.
LogTailRequests []string `json:"log_tail_requests"`
// ControllerLogRequested (v0.116.0) — the operator wants THIS controller's own debug
// ring; the NEXT report ships controller_log_tail (selftail.go). Absent/false on an
// old hub = nothing pending.
ControllerLogRequested bool `json:"controller_log_requested"`
}
// Pusher sends reports to the central hub.
+74
View File
@@ -0,0 +1,74 @@
package report
import (
"log"
"sync"
"time"
)
// Controller self-log tail (v0.116.0 observability) — the CONTROLLER's own debug
// ring riding the report channel, the exact consume-once ACK-flag shape of
// logtail.go (which stays byte-compatible; these are ADDITIVE fields): the hub
// stores a pending per-customer request; the report ACK advertises it as
// controller_log_requested; the NEXT report carries controller_log_tail; the hub
// clears the pending request on arrival. A failed push leaves the hub request
// pending — the next ACK re-arms it (fail-safe retry, no duplicate shipping).
// controllerLogMaxBytes caps the shipped tail (newest lines kept — the ring's
// Lines budget, mirrored agent-side at the same 128 KB).
const controllerLogMaxBytes = 128 * 1024
// ControllerLogTail is the report's on-demand controller ring tail.
type ControllerLogTail struct {
CollectedAt time.Time `json:"collected_at"`
Lines []string `json:"lines"`
}
var (
selfTailMu sync.Mutex
selfTailPending bool
selfTailSource func(maxBytes int) []string
)
// SetPendingControllerLog records the ACK's controller_log_requested flag. The
// hub is the source of truth: an ACK without the flag clears any stale pending.
func SetPendingControllerLog(requested bool) {
selfTailMu.Lock()
defer selfTailMu.Unlock()
selfTailPending = requested
}
// SetControllerLogSource wires the debug ring (web.LogBuffer.Lines) — set once by
// main.go at startup. Unset means a pending request is silently unfulfillable
// (drained but nothing shipped; the hub re-arms on the next ACK).
func SetControllerLogSource(src func(maxBytes int) []string) {
selfTailMu.Lock()
defer selfTailMu.Unlock()
selfTailSource = src
}
// drainPendingControllerLog takes + CLEARS the pending flag and returns the
// source (consume-once controller-side, the drainPendingLogTails shape).
func drainPendingControllerLog() (func(maxBytes int) []string, bool) {
selfTailMu.Lock()
defer selfTailMu.Unlock()
pending := selfTailPending
selfTailPending = false
return selfTailSource, pending
}
// buildControllerLogTail attaches the ring tail when a pull is pending. The INFO
// line is the CUSTOMER-VISIBLE transparency record (it lands in the ring/viewer):
// an operator pull of this box's controller log is never silent.
func buildControllerLogTail(logger *log.Logger) *ControllerLogTail {
src, pending := drainPendingControllerLog()
if !pending || src == nil {
return nil
}
// Logged BEFORE collecting, so the transparency line rides in the tail it announces.
if logger != nil {
logger.Printf("[INFO] [report] operator log pull served (controller ring)")
}
lines := src(controllerLogMaxBytes)
return &ControllerLogTail{CollectedAt: time.Now().UTC(), Lines: lines}
}
+107
View File
@@ -0,0 +1,107 @@
package report
import (
"bytes"
"encoding/json"
"log"
"strings"
"testing"
"time"
)
func resetSelfTail() {
SetPendingControllerLog(false)
SetControllerLogSource(nil)
}
// S2 (controller half): the ACK's controller_log_requested arms the pull; the next
// build attaches the ring tail; the build after that attaches nothing (consume-once).
// Companion red-proof: remove the `selfTailPending = false` drain → the second build
// ships again → the nil assertion fails.
func TestControllerLogTail_ConsumeOnce(t *testing.T) {
t.Cleanup(resetSelfTail)
SetControllerLogSource(func(maxBytes int) []string { return []string{"l1", "l2", "l3"} })
SetPendingControllerLog(true)
var logbuf bytes.Buffer
logger := log.New(&logbuf, "", 0)
first := buildControllerLogTail(logger)
if first == nil || len(first.Lines) != 3 || first.CollectedAt.IsZero() {
t.Fatalf("first build tail = %+v, want the 3 ring lines + collected_at", first)
}
// Customer transparency: the pull is announced in the box's own log.
if !strings.Contains(logbuf.String(), "operator log pull served") {
t.Errorf("transparency INFO line missing: %q", logbuf.String())
}
if second := buildControllerLogTail(logger); second != nil {
t.Errorf("second build shipped again — consume-once broken: %+v", second)
}
}
// The hub is the source of truth: an ACK without the flag clears a stale pending.
func TestControllerLogTail_AckClears(t *testing.T) {
t.Cleanup(resetSelfTail)
SetControllerLogSource(func(int) []string { return []string{"x"} })
SetPendingControllerLog(true)
SetPendingControllerLog(false)
if tail := buildControllerLogTail(nil); tail != nil {
t.Errorf("cleared pending still shipped: %+v", tail)
}
}
// No source wired (defensive) → pending is drained, nothing shipped, no panic.
func TestControllerLogTail_NoSource(t *testing.T) {
t.Cleanup(resetSelfTail)
SetPendingControllerLog(true)
if tail := buildControllerLogTail(nil); tail != nil {
t.Errorf("tail shipped with no source: %+v", tail)
}
}
// S3 app-tail byte-compatibility: the v0.111.0 log_tails wire shape is unchanged by
// the additive controller_log_tail — absent in the steady state (omitempty), and the
// existing keys marshal identically. Companion red-proof: drop `omitempty` from
// ControllerLogTail → the steady-state JSON gains a null key → FAIL.
func TestReportSchema_AppTailUnchangedAndSelfTailAdditive(t *testing.T) {
r := &Report{
Version: 1,
CustomerID: "c1",
LogTails: []LogTail{{
App: "gokapi", CollectedAt: time.Date(2026, 7, 11, 10, 0, 0, 0, time.UTC),
Lines: []string{"a", "b"},
}},
}
raw, err := json.Marshal(r)
if err != nil {
t.Fatal(err)
}
var m map[string]json.RawMessage
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
if _, present := m["controller_log_tail"]; present {
t.Errorf("controller_log_tail present in the steady state — must be omitted (additive)")
}
var tails []map[string]json.RawMessage
if err := json.Unmarshal(m["log_tails"], &tails); err != nil || len(tails) != 1 {
t.Fatalf("log_tails shape changed: %s", m["log_tails"])
}
for _, key := range []string{"app", "collected_at", "lines"} {
if _, ok := tails[0][key]; !ok {
t.Errorf("log_tails entry lost key %q — app-tail flow must stay byte-compatible", key)
}
}
// The ACK parse: old fields + the new flag coexist; absent flag = false.
var pr PushResponse
if err := json.Unmarshal([]byte(`{"status":"ok","log_tail_requests":["gokapi"],"controller_log_requested":true}`), &pr); err != nil {
t.Fatal(err)
}
if len(pr.LogTailRequests) != 1 || !pr.ControllerLogRequested {
t.Errorf("ACK parse = %+v", pr)
}
var old PushResponse
if err := json.Unmarshal([]byte(`{"status":"ok"}`), &old); err != nil || old.ControllerLogRequested {
t.Errorf("old ACK must parse with the flag false: %+v err=%v", old, err)
}
}
+5
View File
@@ -38,6 +38,11 @@ type Report struct {
// LogTails (v0.111.0) — on-demand ordered log tails, present only on the report cycle
// right after the ACK requested them (log_tail_requests). Redacted + capped (logtail.go).
LogTails []LogTail `json:"log_tails,omitempty"`
// ControllerLogTail (v0.116.0) — the controller's OWN debug-ring tail, present only on
// the cycle right after the ACK's controller_log_requested (selftail.go; additive — the
// app-tail flow above is untouched).
ControllerLogTail *ControllerLogTail `json:"controller_log_tail,omitempty"`
}
// SystemReport holds host-level system info.