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
+54
View File
@@ -16,11 +16,14 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"regexp"
"strings"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/logx"
)
// Client talks to one agent local-API endpoint with a pinned leaf + bearer token.
@@ -35,8 +38,15 @@ type Client struct {
// Supports falls back to the route probe.
verMu sync.Mutex
lastAgentVersion string
// logger is the optional per-call DEBUG trace sink (v0.116.0 observability — the
// capture ring holds these even at logging.level=info). nil = silent (unchanged).
logger *log.Logger
}
// SetLogger wires the optional per-call DEBUG trace logger (method, path, status,
// duration + agent-version changes — never bodies or tokens).
func (c *Client) SetLogger(l *log.Logger) { c.logger = l }
// reAgentVersion is the bare-semver shape the publish pipeline enforces (publish-agent.sh) — the
// ONLY header values trusted for capability comparison. Anything else (garbage, "dev", suffixes)
// is ignored and the probe fallback stays in charge.
@@ -50,8 +60,12 @@ func (c *Client) noteAgentVersion(resp *http.Response) {
return
}
c.verMu.Lock()
prev := c.lastAgentVersion
c.lastAgentVersion = v
c.verMu.Unlock()
if prev != v {
logx.Debugf(c.logger, "[agentapi] agent version seen: %s (was %q)", v, prev)
}
}
// AgentVersion returns the last strictly-validated agent version seen on this client's traffic
@@ -799,12 +813,15 @@ func (c *Client) postWithStatus(ctx context.Context, path string, body any) (api
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
start := time.Now()
resp, err := c.hc.Do(req)
if err != nil {
logx.Debugf(c.logger, "[agentapi] POST %s failed after %dms: %v", path, time.Since(start).Milliseconds(), err)
return env, 0, fmt.Errorf("agentapi: POST %s: %w", path, err)
}
defer resp.Body.Close()
c.noteAgentVersion(resp) // v0.82.0 version channel: passive capture on EVERY response
logx.Debugf(c.logger, "[agentapi] POST %s -> %d (%dms)", path, resp.StatusCode, time.Since(start).Milliseconds())
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err := json.Unmarshal(raw, &env); err != nil {
return env, resp.StatusCode, fmt.Errorf("agentapi: POST %s: HTTP %d, bad envelope: %w", path, resp.StatusCode, err)
@@ -812,6 +829,37 @@ func (c *Client) postWithStatus(ctx context.Context, path string, body any) (api
return env, resp.StatusCode, nil
}
// ---- v0.116.0: agent debug-log ring (the Debug page agent tab) ----------------------------
// AgentLogEntry mirrors the agent's GET /debug/logs entry (agent ≥ 0.83.0).
type AgentLogEntry struct {
Timestamp time.Time `json:"timestamp"`
Level string `json:"level"`
Message string `json:"message"`
}
// AgentLogsResponse mirrors the agent's GET /debug/logs data payload.
type AgentLogsResponse struct {
VMID int `json:"vmid"`
Entries []AgentLogEntry `json:"entries"`
Total int `json:"total"`
}
// DebugLogs fetches the agent's always-DEBUG capture ring. Against a pre-0.83
// agent the route is absent → a typed *StatusError with Code 404 (the caller
// renders the "available after the agent's next update" notice — S6).
func (c *Client) DebugLogs(ctx context.Context) (AgentLogsResponse, error) {
var out AgentLogsResponse
data, err := c.get(ctx, "/debug/logs")
if err != nil {
return out, err
}
if err := json.Unmarshal(data, &out); err != nil {
return out, fmt.Errorf("agentapi: parsing debug logs: %w", err)
}
return out, nil
}
// ---- slice 9: host metrics (the customer host-health view) -------------------------------
// HostMetrics mirrors the agent's GET /host/metrics `host` block (shared HostMetrics wire shape).
@@ -908,12 +956,15 @@ func (c *Client) get(ctx context.Context, path string) (json.RawMessage, error)
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.token)
start := time.Now()
resp, err := c.hc.Do(req)
if err != nil {
logx.Debugf(c.logger, "[agentapi] GET %s failed after %dms: %v", path, time.Since(start).Milliseconds(), err)
return nil, fmt.Errorf("agentapi: GET %s: %w", path, err)
}
defer resp.Body.Close()
c.noteAgentVersion(resp) // v0.82.0 version channel: passive capture on EVERY response
logx.Debugf(c.logger, "[agentapi] GET %s -> %d (%dms)", path, resp.StatusCode, time.Since(start).Milliseconds())
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return nil, &StatusError{Path: path, Code: resp.StatusCode}
@@ -941,12 +992,15 @@ func (c *Client) post(ctx context.Context, path string, body any) (json.RawMessa
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
start := time.Now()
resp, err := c.hc.Do(req)
if err != nil {
logx.Debugf(c.logger, "[agentapi] POST %s failed after %dms: %v", path, time.Since(start).Milliseconds(), err)
return nil, fmt.Errorf("agentapi: POST %s: %w", path, err)
}
defer resp.Body.Close()
c.noteAgentVersion(resp) // v0.82.0 version channel: passive capture on EVERY response
logx.Debugf(c.logger, "[agentapi] POST %s -> %d (%dms)", path, resp.StatusCode, time.Since(start).Milliseconds())
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
return nil, fmt.Errorf("agentapi: POST %s: HTTP %d", path, resp.StatusCode)
+17 -5
View File
@@ -7,6 +7,7 @@ import (
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/logx"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
@@ -109,13 +110,21 @@ type SupportCache struct {
// window, probe on miss). The probe runs OUTSIDE the lock — concurrent misses may double-probe
// (harmless: the probe is one cheap GET).
func (sc *SupportCache) Supports(ctx context.Context, p SupportProber, f Feature) SupportState {
state, _ := sc.SupportsWithSource(ctx, p, f)
return state
}
// SupportsWithSource is Supports plus the DECISION SOURCE ("version" | "probe-cache" |
// "probe" | "unregistered") — the v0.116.0 observability extension so the gate line can
// say HOW the verdict was reached. Behavior is byte-identical to v0.115.0 Supports.
func (sc *SupportCache) SupportsWithSource(ctx context.Context, p SupportProber, f Feature) (SupportState, string) {
probe, ok := featureProbes[f]
if !ok {
return SupportUnknown // unregistered feature — never refuse on a table gap
return SupportUnknown, "unregistered" // unregistered feature — never refuse on a table gap
}
if vr, hasVer := p.(AgentVersionReporter); hasVer {
if state, decided := supportsByVersion(vr.AgentVersion(), f); decided {
return state
return state, "version"
}
}
sc.mu.Lock()
@@ -125,7 +134,7 @@ func (sc *SupportCache) Supports(ctx context.Context, p SupportProber, f Feature
}
if e, hit := sc.entries[f]; hit && nowFn().Sub(e.at) < supportTTL {
sc.mu.Unlock()
return e.state
return e.state, "probe-cache"
}
sc.mu.Unlock()
@@ -138,14 +147,17 @@ func (sc *SupportCache) Supports(ctx context.Context, p SupportProber, f Feature
sc.entries[f] = supportEntry{state: state, at: nowFn()}
sc.mu.Unlock()
}
return state
return state, "probe"
}
// Supports probes (cached, TTL 5m, both polarities) whether the connected agent provides the
// feature. 2xx ⇒ Yes. 404 ⇒ No. Anything else ⇒ Unknown (never "too old"). The web layer drives
// the same machinery through its netAgent seam (Server.netFeatures) so tests can fake the probe.
func (c *Client) Supports(ctx context.Context, f Feature) SupportState {
return c.features.Supports(ctx, c, f)
state, source := c.features.SupportsWithSource(ctx, c, f)
logx.Debugf(c.logger, "[agentapi] Supports(%s) = %s (source=%s agent_version=%q)",
f, state, source, c.AgentVersion())
return state
}
// supportsByVersion decides a feature by version comparison alone. decided=false (unknown/garbage