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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user