Skip to content

Commit 6373fd0

Browse files
sarg3ntclaude
andcommitted
fix(agent): address Copilot review on Phase 2 Resources (#112)
Four review findings, all addressed: 1. Probe was mutating g.paths despite the ProbeableGear contract that Probe must be side-effect-free. Refactor: extract pathsFromDeps as a shared deps→map helper, add resolveLogPathWith(src, overrides) so Probe operates on a local override map. Initialize is now the single place g.paths gets written. 2. Resources was always populated with a "log_sources" key even when no readable log files were found, so the JSON envelope's `omitempty` tag never fired and the wire format gained a permanent (empty-slice) field. Only attach Resources when log_sources actually has entries. 3. Manager.ProbeResults returned a shallow copy — callers could mutate nested Capabilities/Resources maps and race against manager state under load. Added cloneProbeResult that duplicates Capabilities as a fresh map and JSON-round-trips Resources (so nested slices and sub-maps come back as fresh allocations). JSON round-trip is the same cost the capabilities endpoint encoder pays anyway, and falls back to a shallow Resources copy if marshaling fails. 4. Test comment claimed "decode through json.RawMessage" but the test actually decoded straight into map[string]any. Switch the test to a two-stage decode: first peel the outer envelope into json.RawMessage per gear (catches field-name regressions in the wire format), then decode the resources object into a typed shape and assert on payload contents. Avoids Go's `any` decoding quirks in the wire-format guard. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0fe7e92 commit 6373fd0

3 files changed

Lines changed: 159 additions & 49 deletions

File tree

gearbox-agent/internal/framework/gear/manager.go

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,17 +147,81 @@ func (m *Manager) isLoaded(name string) bool {
147147
}
148148

149149
// ProbeResults returns a snapshot of every gear's probe verdict. Useful
150-
// for the upcoming /api/v1/system/capabilities endpoint and for tests.
150+
// for the /api/v1/system/capabilities endpoint and for tests.
151+
//
152+
// The returned map and every nested Capabilities / Resources map are
153+
// independent copies — callers can freely mutate them without racing
154+
// against background re-probes (when those land) or aliasing the
155+
// manager's internal state. JSON encoding deeply traverses the
156+
// Resources tree, so the cost stays in O(snapshot) at the request
157+
// boundary instead of being a hidden mutation hazard for downstream
158+
// consumers (issue #112 review).
151159
func (m *Manager) ProbeResults() map[string]ProbeResult {
152160
m.mu.RLock()
153161
defer m.mu.RUnlock()
154162
out := make(map[string]ProbeResult, len(m.probed))
155163
for k, v := range m.probed {
156-
out[k] = v
164+
out[k] = cloneProbeResult(v)
165+
}
166+
return out
167+
}
168+
169+
// cloneProbeResult returns a deep copy of r — Capabilities is
170+
// duplicated as a fresh map[string]string, Resources is duplicated by
171+
// JSON round-trip because each gear picks its own shape and we can't
172+
// know the concrete types statically. JSON round-trip is the same
173+
// cost the /api/v1/system/capabilities encoder pays anyway, so it's
174+
// the cheapest safe option that handles arbitrary nested data.
175+
//
176+
// Falls back to a shallow Resources copy if JSON encoding fails (a
177+
// gear sneaked an un-marshalable value into the map — should never
178+
// happen because the same value would crash the capabilities
179+
// endpoint). Aliasing is the worst case here; better than panicking
180+
// at a fan-out call site.
181+
func cloneProbeResult(r ProbeResult) ProbeResult {
182+
out := ProbeResult{
183+
Status: r.Status,
184+
Reason: r.Reason,
185+
}
186+
if r.Capabilities != nil {
187+
out.Capabilities = make(map[string]string, len(r.Capabilities))
188+
for k, v := range r.Capabilities {
189+
out.Capabilities[k] = v
190+
}
191+
}
192+
if len(r.Resources) > 0 {
193+
if cloned, err := cloneResourcesViaJSON(r.Resources); err == nil {
194+
out.Resources = cloned
195+
} else {
196+
// Shallow fallback. See function comment.
197+
out.Resources = make(map[string]any, len(r.Resources))
198+
for k, v := range r.Resources {
199+
out.Resources[k] = v
200+
}
201+
}
157202
}
158203
return out
159204
}
160205

206+
// cloneResourcesViaJSON round-trips the map through encoding/json so
207+
// nested slices and sub-maps come back as fresh allocations. After
208+
// decode, every leaf is a JSON-typed value (float64, string, bool,
209+
// nil) — the same shape downstream consumers see when fetching
210+
// /api/v1/system/capabilities over the wire, so callers can't
211+
// accidentally rely on Go-typed values that wouldn't survive the
212+
// JSON boundary.
213+
func cloneResourcesViaJSON(in map[string]any) (map[string]any, error) {
214+
b, err := json.Marshal(in)
215+
if err != nil {
216+
return nil, err
217+
}
218+
var out map[string]any
219+
if err := json.Unmarshal(b, &out); err != nil {
220+
return nil, err
221+
}
222+
return out, nil
223+
}
224+
161225
// logProbeTable renders the visual probe summary to the table writer and
162226
// logs a structured completion line via slog. The visual table goes to
163227
// stderr (the systemd journal in production) because slog text handlers

gearbox-agent/internal/framework/gear/manager_probe_test.go

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -332,38 +332,53 @@ func TestCapabilitiesEndpointSurfacesResources(t *testing.T) {
332332
t.Fatalf("status = %d, want 200", rr.Code)
333333
}
334334

335-
// Decode through json.RawMessage to confirm the field round-trips
336-
// without relying on the in-process Go type — the dashboard sees
337-
// JSON over the wire, not a Go struct.
338-
var raw struct {
335+
// Two-stage decode: first peel the outer envelope into
336+
// json.RawMessage per gear so we can verify the `resources` key
337+
// is present in the raw bytes (catches name-tag regressions like
338+
// renaming the field to "Resources" or "extra"). Then decode
339+
// the resources object into a typed shape and check the actual
340+
// payload contents. Decoding through RawMessage rather than
341+
// straight into map[string]any avoids relying on Go's `any`
342+
// decoding quirks for the wire-format guard.
343+
var envelope struct {
339344
Gears map[string]struct {
340-
Status string `json:"status"`
341-
Resources map[string]any `json:"resources"`
345+
Status string `json:"status"`
346+
Resources json.RawMessage `json:"resources"`
342347
} `json:"gears"`
343348
}
344-
if err := json.Unmarshal(rr.Body.Bytes(), &raw); err != nil {
345-
t.Fatalf("decode response: %v", err)
349+
if err := json.Unmarshal(rr.Body.Bytes(), &envelope); err != nil {
350+
t.Fatalf("decode envelope: %v", err)
346351
}
347-
alpha, ok := raw.Gears["alpha"]
352+
alpha, ok := envelope.Gears["alpha"]
348353
if !ok {
349354
t.Fatalf("alpha missing from response")
350355
}
351-
logSources, ok := alpha.Resources["log_sources"].([]any)
352-
if !ok {
353-
t.Fatalf("alpha.Resources[\"log_sources\"] type = %T, want []any (JSON-decoded slice)", alpha.Resources["log_sources"])
356+
if len(alpha.Resources) == 0 {
357+
t.Fatalf("alpha.resources missing from envelope; got body %s", rr.Body.String())
358+
}
359+
360+
var resources struct {
361+
LogSources []struct {
362+
Name string `json:"name"`
363+
DisplayName string `json:"display_name"`
364+
Path string `json:"path"`
365+
} `json:"log_sources"`
354366
}
355-
if len(logSources) != 1 {
356-
t.Fatalf("alpha.Resources[\"log_sources\"] = %v, want one entry", logSources)
367+
if err := json.Unmarshal(alpha.Resources, &resources); err != nil {
368+
t.Fatalf("decode alpha.resources: %v", err)
357369
}
358-
first, ok := logSources[0].(map[string]any)
359-
if !ok {
360-
t.Fatalf("first log source type = %T, want map[string]any", logSources[0])
370+
if len(resources.LogSources) != 1 {
371+
t.Fatalf("log_sources = %v, want one entry", resources.LogSources)
372+
}
373+
got := resources.LogSources[0]
374+
if got.Name != "nginx" {
375+
t.Errorf("name = %q, want nginx", got.Name)
361376
}
362-
if first["name"] != "nginx" {
363-
t.Errorf("first.name = %v, want nginx", first["name"])
377+
if got.DisplayName != "nginx" {
378+
t.Errorf("display_name = %q, want nginx", got.DisplayName)
364379
}
365-
if first["path"] != "/var/log/nginx/access.log" {
366-
t.Errorf("first.path = %v, want /var/log/nginx/access.log", first["path"])
380+
if got.Path != "/var/log/nginx/access.log" {
381+
t.Errorf("path = %q, want /var/log/nginx/access.log", got.Path)
367382
}
368383
}
369384

gearbox-agent/internal/gears/accesslog/plugin.go

Lines changed: 57 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -162,19 +162,22 @@ var accessLogSourceDisplayName = map[string]string{
162162
// Each entry is {"name", "display_name", "path"} for one discovered
163163
// web-server access log. Older dashboards that don't know about
164164
// Resources continue to read the flat Capabilities map; both views
165-
// are kept in sync.
165+
// are kept in sync. When no readable log file is found at all, the
166+
// Resources field is left unset so the JSON envelope omits it and
167+
// callers fall back to the flat Capabilities map.
168+
//
169+
// Side-effect-free: Probe builds the operator-override map as a
170+
// local rather than mutating g.paths, so the function honors the
171+
// ProbeableGear contract that probing must be free of state writes.
172+
// Initialize re-runs the dep-to-paths copy below to populate g.paths
173+
// for the handler path that needs it.
166174
func (g *Gear) Probe(ctx context.Context, deps gear.Dependencies) gear.ProbeResult {
167-
g.paths = map[string]string{
168-
"haproxy": deps.HAProxyAccessLog,
169-
"nginx": deps.NginxAccessLog,
170-
"apache": deps.ApacheAccessLog,
171-
"caddy": deps.CaddyAccessLog,
172-
}
175+
overrides := pathsFromDeps(deps)
173176

174177
caps := map[string]string{}
175178
logSources := []map[string]string{}
176179
for _, src := range []string{"haproxy", "nginx", "apache", "caddy"} {
177-
path := g.resolveLogPath(src)
180+
path := g.resolveLogPathWith(src, overrides)
178181
if path == "" {
179182
continue
180183
}
@@ -185,27 +188,40 @@ func (g *Gear) Probe(ctx context.Context, deps gear.Dependencies) gear.ProbeResu
185188
"path": path,
186189
})
187190
}
188-
resources := map[string]any{
189-
"log_sources": logSources,
191+
// Only attach Resources when we actually have a payload — keeps
192+
// the JSON envelope's `resources` field truly omitempty.
193+
if len(logSources) > 0 {
194+
return gear.ProbeAvailableWithResources(
195+
"access-log endpoint registered",
196+
caps,
197+
map[string]any{"log_sources": logSources},
198+
)
190199
}
191-
return gear.ProbeAvailableWithResources("access-log endpoint registered", caps, resources)
200+
return gear.ProbeAvailable("access-log endpoint registered", caps)
192201
}
193202

194-
// Initialize captures the path overrides for later use by the
195-
// handler. Probe already populated paths; this re-runs it because
196-
// Probe runs before Initialize on first boot, and we want explicit
197-
// re-resolution on Initialize so test harnesses that construct a
198-
// gear without calling Probe still get usable paths.
199-
func (g *Gear) Initialize(ctx context.Context, deps gear.Dependencies) error {
200-
if err := g.BaseGear.Initialize(ctx, deps); err != nil {
201-
return err
202-
}
203-
g.paths = map[string]string{
203+
// pathsFromDeps extracts the operator-overrides map from
204+
// gear.Dependencies. Shared by Probe (local-only, side-effect-free)
205+
// and Initialize (mutates g.paths so handlers can read it later)
206+
// so the deps→map mapping has one source of truth.
207+
func pathsFromDeps(deps gear.Dependencies) map[string]string {
208+
return map[string]string{
204209
"haproxy": deps.HAProxyAccessLog,
205210
"nginx": deps.NginxAccessLog,
206211
"apache": deps.ApacheAccessLog,
207212
"caddy": deps.CaddyAccessLog,
208213
}
214+
}
215+
216+
// Initialize captures the path overrides for later use by the
217+
// handler. Probe is side-effect-free (#112 review) so this is the
218+
// single place g.paths gets written; the handler reads g.paths via
219+
// resolveLogPath during request handling.
220+
func (g *Gear) Initialize(ctx context.Context, deps gear.Dependencies) error {
221+
if err := g.BaseGear.Initialize(ctx, deps); err != nil {
222+
return err
223+
}
224+
g.paths = pathsFromDeps(deps)
209225
return nil
210226
}
211227

@@ -316,12 +332,27 @@ func parseWithFallback(primary, fallback accesslog.Parser, raw string) *accesslo
316332
return fallback.Parse(raw)
317333
}
318334

319-
// resolveLogPath returns the access-log path for src: the operator
320-
// override if set and readable, the well-known default if readable,
321-
// or "" when neither exists. Apache gets a second-chance lookup
322-
// against the RHEL-style path.
335+
// resolveLogPath returns the access-log path for src using the
336+
// operator-override map captured in g.paths. Thin wrapper over
337+
// resolveLogPathWith so existing handler callers keep their
338+
// `g.resolveLogPath(src)` ergonomics; Probe (side-effect-free)
339+
// uses resolveLogPathWith directly with a local map.
323340
func (g *Gear) resolveLogPath(src string) string {
324-
if override, ok := g.paths[src]; ok && override != "" {
341+
return g.resolveLogPathWith(src, g.paths)
342+
}
343+
344+
// resolveLogPathWith is the underlying resolver. Takes the
345+
// operator-override map explicitly so the caller (Probe / handler /
346+
// test) can avoid touching shared state:
347+
//
348+
// - the operator override if set and readable, OR
349+
// - the well-known default if readable, OR
350+
// - "" when neither exists.
351+
//
352+
// Apache gets a second-chance lookup against the RHEL-style path
353+
// because Debian and RHEL ship the log under different paths.
354+
func (g *Gear) resolveLogPathWith(src string, overrides map[string]string) string {
355+
if override, ok := overrides[src]; ok && override != "" {
325356
// Operator explicitly pointed us at a path — trust them.
326357
// If the path isn't readable the endpoint surfaces "tail
327358
// failed" rather than silently falling back to a

0 commit comments

Comments
 (0)