cb692f8788
Capture layer: applog.New returns (logger, Ring) — slog fan-out, stderr at the configured level, ~1000-entry ring fixed at LevelDebug (remote diagnostics without a config flip). GET /debug/logs (token-authed, ?raw=1) + request-level DEBUG middleware. Heartbeat log-pull mirrors the report logtail pattern: envelope log_tail_requested -> next heartbeat carries log_tail (128KB cap, consume-once, failed-push retry proven). Gap-fill sweep over netverify/ netstorage/netmount/signedjobs/selfupdate/disks/controller-swap/desired/loop. Red-proofs: ring-at-emit-level FAILs capture test; drain removed FAILs consume-once; dropped phase line FAILs the S7 log-sequence smoke. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
131 lines
4.3 KiB
Go
131 lines
4.3 KiB
Go
package localapi
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"strings"
|
|
"testing"
|
|
|
|
applog "gitea.dooplex.hu/admin/felhom-agent/internal/log"
|
|
)
|
|
|
|
// newDebugLogServer builds a minimal server with the debug ring wired and one line
|
|
// of each level captured (emit level info — the capture-at-info posture).
|
|
func newDebugLogServer(t *testing.T) (*Server, *applog.Ring) {
|
|
t.Helper()
|
|
logger, ring := applog.NewWithWriter(&bytes.Buffer{}, "info", 50)
|
|
logger.Debug("netverify: flow detail", "step", "probe")
|
|
logger.Info("netmount: ensured network mount", "name", "nas")
|
|
srv, err := NewServer(Options{
|
|
ListenAddr: "127.0.0.1:0",
|
|
Guests: &fakeGuests{},
|
|
Backups: &fakeBackups{},
|
|
Store: &fakeStore{},
|
|
Storage: fakeStorage{},
|
|
Tokens: staticTokens{"A": 8200},
|
|
LogRing: ring,
|
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("new server: %v", err)
|
|
}
|
|
return srv, ring
|
|
}
|
|
|
|
// GET /debug/logs serves the ring's entries — INCLUDING the DEBUG line captured at
|
|
// emit level info (the endpoint exists so the controller's agent tab can show flow
|
|
// detail without a config flip). Companion red-proof: unwire LogRing → 503.
|
|
func TestDebugLogs_ServesRingEntriesIncludingDebug(t *testing.T) {
|
|
srv, _ := newDebugLogServer(t)
|
|
w := do(t, srv.Handler(), "GET", "/debug/logs", "A", "")
|
|
if w.Code != 200 {
|
|
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var resp struct {
|
|
OK bool `json:"ok"`
|
|
Data struct {
|
|
Entries []applog.Entry `json:"entries"`
|
|
Total int `json:"total"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if !resp.OK || resp.Data.Total != 2 || len(resp.Data.Entries) != 2 {
|
|
t.Fatalf("resp = %+v, want ok with 2 entries", resp)
|
|
}
|
|
if resp.Data.Entries[0].Level != "DEBUG" || !strings.Contains(resp.Data.Entries[0].Message, "flow detail") {
|
|
t.Errorf("entry 0 = %+v, want the captured DEBUG line", resp.Data.Entries[0])
|
|
}
|
|
}
|
|
|
|
// ?raw=1 serves plain text lines (the controller viewer's raw variant).
|
|
func TestDebugLogs_RawVariant(t *testing.T) {
|
|
srv, _ := newDebugLogServer(t)
|
|
w := do(t, srv.Handler(), "GET", "/debug/logs?raw=1", "A", "")
|
|
if w.Code != 200 || !strings.HasPrefix(w.Header().Get("Content-Type"), "text/plain") {
|
|
t.Fatalf("status=%d content-type=%q", w.Code, w.Header().Get("Content-Type"))
|
|
}
|
|
body := w.Body.String()
|
|
if !strings.Contains(body, "[DEBUG]") || !strings.Contains(body, "ensured network mount") {
|
|
t.Errorf("raw body missing lines:\n%s", body)
|
|
}
|
|
}
|
|
|
|
// The route is auth-gated like every sibling (no token → 401), and reports "not
|
|
// configured" (503) when the ring is not wired — never a panic, never an empty 200.
|
|
func TestDebugLogs_AuthAndUnconfigured(t *testing.T) {
|
|
srv, _ := newDebugLogServer(t)
|
|
if w := do(t, srv.Handler(), "GET", "/debug/logs", "", ""); w.Code != 401 {
|
|
t.Errorf("unauthed status = %d, want 401", w.Code)
|
|
}
|
|
bare, err := NewServer(Options{
|
|
ListenAddr: "127.0.0.1:0",
|
|
Guests: &fakeGuests{},
|
|
Backups: &fakeBackups{},
|
|
Store: &fakeStore{},
|
|
Storage: fakeStorage{},
|
|
Tokens: staticTokens{"A": 8200},
|
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("new server: %v", err)
|
|
}
|
|
if w := do(t, bare.Handler(), "GET", "/debug/logs", "A", ""); w.Code != 503 {
|
|
t.Errorf("unconfigured status = %d, want 503", w.Code)
|
|
}
|
|
}
|
|
|
|
// The request middleware logs method/path/status/duration at DEBUG into the wired
|
|
// logger — proven through a ring-backed server logger (the line must land in the ring).
|
|
func TestRequestLogging_DebugLineInRing(t *testing.T) {
|
|
logger, ring := applog.NewWithWriter(&bytes.Buffer{}, "info", 50)
|
|
srv, err := NewServer(Options{
|
|
ListenAddr: "127.0.0.1:0",
|
|
Guests: &fakeGuests{},
|
|
Backups: &fakeBackups{},
|
|
Store: &fakeStore{},
|
|
Storage: fakeStorage{},
|
|
Tokens: staticTokens{"A": 8200},
|
|
LogRing: ring,
|
|
Logger: logger,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("new server: %v", err)
|
|
}
|
|
do(t, srv.Handler(), "GET", "/storage", "A", "")
|
|
entries, _ := ring.Entries(0)
|
|
found := false
|
|
for _, e := range entries {
|
|
if e.Level == "DEBUG" && strings.Contains(e.Message, "local-api: request") &&
|
|
strings.Contains(e.Message, "path=/storage") {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("no request DEBUG line in the ring; entries=%+v", entries)
|
|
}
|
|
}
|