Skip to content

Commit e41387c

Browse files
committed
fix(#91): address Copilot review findings on PR #93
- api_capabilities.go: gate /api/{boxID}/capabilities on ComponentMetrics + PermissionView. The manifest enumerates installed services on the host, enough for fingerprinting in multi-tenant deploys — a user without metrics:view shouldn't enumerate the software inventory. Mirrors APIMetricsSummaryHandler's gate. - history.templ: no-HAProxy banner now picks copy from the actual capability entry — not_installed / inaccessible / disabled each get distinct guidance, and the agent's `reason` is surfaced verbatim. Previous copy ("Install HAProxy") pointed at the wrong fix when the binary was present but stats unreachable. - capabilities_cache.go: cache key is now (boxID, agentURL) so an operator editing a box's Agent URL gets fresh capabilities on the next render rather than stale data until the 5-min TTL expires. Invalidate() drops every entry for the boxID regardless of URL. - haproxy_config.go: invalidate the capabilities cache on box update and delete so Agent URL / API key edits take effect immediately. - api_metrics_insights.go: capability gating uses the sourceHAProxy constant instead of a duplicate "haproxy" literal — prevents future drift between KPI source IDs and capability lookups. New tests: - TestCapabilitiesCacheDifferentAgentURLBypassesCache — same boxID with different agent URLs returns each agent's actual verdict, not stale. - TestCapabilitiesCacheInvalidateDropsAllAgentURLsForBox — Invalidate drops every entry for a boxID across all URLs it was fetched against.
1 parent e68c147 commit e41387c

6 files changed

Lines changed: 204 additions & 34 deletions

File tree

gearbox/internal/framework/agent/capabilities_cache.go

Lines changed: 53 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -49,23 +49,40 @@ func (b *BoxCapabilities) Entry(gearName string) (CapabilityEntry, bool) {
4949
return e, ok
5050
}
5151

52-
// CapabilitiesCache memoises agent capability fetches per box. Dashboard
53-
// pages call into this on every render that needs to decide what to
54-
// surface; without the cache, that's one synchronous round-trip per
55-
// render. TTL is short so a recently-restarted agent's new probe table
56-
// shows up without operator intervention; reconnect events should
57-
// invalidate explicitly via Invalidate() for an immediate refresh.
52+
// CapabilitiesCache memoises agent capability fetches per (box, agent
53+
// URL) pair. Dashboard pages call into this on every render that needs
54+
// to decide what to surface; without the cache, that's one synchronous
55+
// round-trip per render. TTL is short so a recently-restarted agent's
56+
// new probe table shows up without operator intervention; reconnect
57+
// events should invalidate explicitly via Invalidate() for an immediate
58+
// refresh.
59+
//
60+
// The cache key includes the agent URL (not just the box ID) so that an
61+
// operator editing a box's Agent URL — or re-pointing the same box ID
62+
// at a different host — gets fresh capabilities on the next render
63+
// rather than stale data from the prior agent until the 5-minute TTL
64+
// expires. The API key is intentionally not part of the key (rotations
65+
// don't change which gears the host runs; if the new key is wrong the
66+
// fetch errors and the negative-cache entry expires normally).
5867
//
5968
// Negative results (fetch errors, agent dead) are also cached for the
6069
// TTL window so a flaky or unreachable agent doesn't drag down every
6170
// page render with a fresh failed call.
6271
type CapabilitiesCache struct {
6372
mu sync.RWMutex
64-
entries map[string]*cachedCapabilities
73+
entries map[cacheKey]*cachedCapabilities
6574
ttl time.Duration
6675
fetchTimeout time.Duration
6776
}
6877

78+
// cacheKey identifies one cached entry by (boxID, agentURL). Splitting
79+
// into a struct rather than concatenating strings sidesteps any chance
80+
// of separator collision in operator-supplied URLs.
81+
type cacheKey struct {
82+
boxID string
83+
agentURL string
84+
}
85+
6986
type cachedCapabilities struct {
7087
caps *BoxCapabilities // nil when the last fetch errored
7188
err error // captured on failure for callers that want it
@@ -78,21 +95,27 @@ type cachedCapabilities struct {
7895
// 3s matches the value the Gears settings page used pre-cache.
7996
func NewCapabilitiesCache(ttl, fetchTimeout time.Duration) *CapabilitiesCache {
8097
return &CapabilitiesCache{
81-
entries: make(map[string]*cachedCapabilities),
98+
entries: make(map[cacheKey]*cachedCapabilities),
8299
ttl: ttl,
83100
fetchTimeout: fetchTimeout,
84101
}
85102
}
86103

87-
// Get returns the cached capabilities for boxID. If the entry is missing
88-
// or older than the TTL, the cache fetches fresh capabilities from
89-
// agentURL using the supplied API key. On fetch error, returns (nil, err);
90-
// the error is also cached for the TTL window. Callers should treat
91-
// (nil, err) as "unknown" and fail open — locking users out of pages
92-
// because the agent is briefly unreachable is worse than briefly showing
93-
// gears that may not be available.
104+
// Get returns the cached capabilities for (boxID, agentURL). If the
105+
// entry is missing or older than the TTL, the cache fetches fresh
106+
// capabilities from agentURL using the supplied API key. On fetch error,
107+
// returns (nil, err); the error is also cached for the TTL window.
108+
// Callers should treat (nil, err) as "unknown" and fail open — locking
109+
// users out of pages because the agent is briefly unreachable is worse
110+
// than briefly showing gears that may not be available.
111+
//
112+
// Changing agentURL for the same boxID produces a different cache entry,
113+
// so config edits take effect on the next call rather than waiting out
114+
// the TTL. The previous entry expires naturally; no explicit cleanup
115+
// needed for the rare case of an operator-driven URL change.
94116
func (c *CapabilitiesCache) Get(boxID, agentURL, apiKey string) (*BoxCapabilities, error) {
95-
if entry, ok := c.lookup(boxID); ok {
117+
k := cacheKey{boxID: boxID, agentURL: agentURL}
118+
if entry, ok := c.lookup(k); ok {
96119
return entry.caps, entry.err
97120
}
98121

@@ -106,7 +129,7 @@ func (c *CapabilitiesCache) Get(boxID, agentURL, apiKey string) (*BoxCapabilitie
106129
}
107130

108131
c.mu.Lock()
109-
c.entries[boxID] = &cachedCapabilities{caps: caps, err: err, fetchedAt: now}
132+
c.entries[k] = &cachedCapabilities{caps: caps, err: err, fetchedAt: now}
110133
c.mu.Unlock()
111134

112135
return caps, err
@@ -115,10 +138,10 @@ func (c *CapabilitiesCache) Get(boxID, agentURL, apiKey string) (*BoxCapabilitie
115138
// lookup returns a non-stale entry under the read lock, or (nil, false)
116139
// if missing/expired. Split out so Get's refresh path can drop the read
117140
// lock before doing network I/O.
118-
func (c *CapabilitiesCache) lookup(boxID string) (*cachedCapabilities, bool) {
141+
func (c *CapabilitiesCache) lookup(k cacheKey) (*cachedCapabilities, bool) {
119142
c.mu.RLock()
120143
defer c.mu.RUnlock()
121-
entry, ok := c.entries[boxID]
144+
entry, ok := c.entries[k]
122145
if !ok {
123146
return nil, false
124147
}
@@ -128,20 +151,25 @@ func (c *CapabilitiesCache) lookup(boxID string) (*cachedCapabilities, bool) {
128151
return entry, true
129152
}
130153

131-
// Invalidate drops the cached entry for boxID, forcing a fresh fetch on
132-
// the next Get. Call this when the box reconnects so a restarted agent's
133-
// new probe table is picked up immediately rather than at the next TTL
154+
// Invalidate drops every cached entry for boxID, regardless of the agent
155+
// URL it was fetched against. Call this when the box reconnects or when
156+
// the box's config changes (URL or API key edited), so the next Get
157+
// refetches against the current settings rather than at the next TTL
134158
// boundary.
135159
func (c *CapabilitiesCache) Invalidate(boxID string) {
136160
c.mu.Lock()
137-
delete(c.entries, boxID)
138-
c.mu.Unlock()
161+
defer c.mu.Unlock()
162+
for k := range c.entries {
163+
if k.boxID == boxID {
164+
delete(c.entries, k)
165+
}
166+
}
139167
}
140168

141169
// InvalidateAll drops every cached entry. Useful when global config that
142170
// affects probing changes (rare) and in tests.
143171
func (c *CapabilitiesCache) InvalidateAll() {
144172
c.mu.Lock()
145-
c.entries = make(map[string]*cachedCapabilities)
173+
c.entries = make(map[cacheKey]*cachedCapabilities)
146174
c.mu.Unlock()
147175
}

gearbox/internal/framework/agent/capabilities_cache_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,78 @@ func TestBoxCapabilitiesAccessors(t *testing.T) {
162162
}
163163
}
164164

165+
func TestCapabilitiesCacheDifferentAgentURLBypassesCache(t *testing.T) {
166+
srv, hits := newCapabilitiesServer(t, CapabilitiesResponse{
167+
Gears: map[string]CapabilityEntry{"haproxy": {Status: "available"}},
168+
})
169+
defer srv.Close()
170+
srv2, hits2 := newCapabilitiesServer(t, CapabilitiesResponse{
171+
Gears: map[string]CapabilityEntry{"haproxy": {Status: "not_installed"}},
172+
})
173+
defer srv2.Close()
174+
175+
cache := NewCapabilitiesCache(5*time.Minute, 2*time.Second)
176+
177+
// Fetch against srv, then against srv2 with the same boxID. The
178+
// second call must NOT serve cached data from srv — operator edits
179+
// to the Agent URL should take effect on the next render.
180+
caps1, err := cache.Get("box-1", srv.URL, "test-key")
181+
if err != nil || !caps1.IsAvailable("haproxy") {
182+
t.Fatalf("first Get: want available, got caps=%+v err=%v", caps1, err)
183+
}
184+
caps2, err := cache.Get("box-1", srv2.URL, "test-key")
185+
if err != nil {
186+
t.Fatalf("second Get: %v", err)
187+
}
188+
if caps2.IsAvailable("haproxy") {
189+
t.Error("expected second Get to reflect srv2's not_installed verdict, got available (stale cache)")
190+
}
191+
192+
if hits.Load() != 1 {
193+
t.Errorf("srv: expected 1 hit, got %d", hits.Load())
194+
}
195+
if hits2.Load() != 1 {
196+
t.Errorf("srv2: expected 1 hit, got %d", hits2.Load())
197+
}
198+
}
199+
200+
func TestCapabilitiesCacheInvalidateDropsAllAgentURLsForBox(t *testing.T) {
201+
srv, hits := newCapabilitiesServer(t, CapabilitiesResponse{
202+
Gears: map[string]CapabilityEntry{"haproxy": {Status: "available"}},
203+
})
204+
defer srv.Close()
205+
srv2, hits2 := newCapabilitiesServer(t, CapabilitiesResponse{
206+
Gears: map[string]CapabilityEntry{"haproxy": {Status: "available"}},
207+
})
208+
defer srv2.Close()
209+
210+
cache := NewCapabilitiesCache(5*time.Minute, 2*time.Second)
211+
if _, err := cache.Get("box-1", srv.URL, "k"); err != nil {
212+
t.Fatalf("seed srv: %v", err)
213+
}
214+
if _, err := cache.Get("box-1", srv2.URL, "k"); err != nil {
215+
t.Fatalf("seed srv2: %v", err)
216+
}
217+
218+
cache.Invalidate("box-1")
219+
220+
if _, err := cache.Get("box-1", srv.URL, "k"); err != nil {
221+
t.Fatalf("post-invalidate srv: %v", err)
222+
}
223+
if _, err := cache.Get("box-1", srv2.URL, "k"); err != nil {
224+
t.Fatalf("post-invalidate srv2: %v", err)
225+
}
226+
227+
// Each backend should have been hit twice: once for the initial
228+
// seed, once after invalidation dropped both entries for box-1.
229+
if got := hits.Load(); got != 2 {
230+
t.Errorf("srv hits = %d, want 2", got)
231+
}
232+
if got := hits2.Load(); got != 2 {
233+
t.Errorf("srv2 hits = %d, want 2", got)
234+
}
235+
}
236+
165237
func TestCapabilitiesCacheInvalidateAll(t *testing.T) {
166238
srv, hits := newCapabilitiesServer(t, CapabilitiesResponse{
167239
Gears: map[string]CapabilityEntry{"haproxy": {Status: "available"}},

gearbox/internal/framework/handler/api_capabilities.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"time"
77

88
"github.com/go-chi/chi/v5"
9+
"github.com/sarg3nt/gearbox/internal/framework/models"
910
)
1011

1112
// APICapabilitiesResponse is the JSON envelope returned by
@@ -43,10 +44,20 @@ type APICapabilityEntry struct {
4344
// metrics gear's frontend (and any future source-aware UI) to hide
4445
// cards / KPIs for sources the box can't produce.
4546
//
47+
// Gated on ComponentMetrics + PermissionView. The manifest enumerates
48+
// detected services on the host, which is enough information for
49+
// fingerprinting in a multi-tenant deploy — a user who can't see
50+
// metrics has no business knowing whether the host runs nginx vs
51+
// Apache vs Caddy. Mirrors APIMetricsSummaryHandler's gate.
52+
//
4653
// Returns 503 when the box isn't agent-backed or capabilities can't
4754
// be fetched — callers should fail open (show the full UI) on errors
4855
// so a flaky agent doesn't lock the user out.
4956
func (h *Handler) APIBoxCapabilitiesHandler(w http.ResponseWriter, r *http.Request) {
57+
if !h.authManager.HasPermission(r, models.ComponentMetrics, models.PermissionView) {
58+
http.Error(w, "Forbidden: insufficient permissions to view metrics", http.StatusForbidden)
59+
return
60+
}
5061
boxID := chi.URLParam(r, "boxID")
5162
if boxID == "" {
5263
http.Error(w, "Server ID required", http.StatusBadRequest)

gearbox/internal/framework/handler/api_metrics_insights.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ func (h *Handler) APIMetricsSummaryHandler(w http.ResponseWriter, r *http.Reques
8888
// down to nothing.
8989
haproxyAvailable := true
9090
if caps, ok := h.getBoxCapabilities(boxID); ok && caps != nil {
91-
if entry, present := caps.Entry("haproxy"); present {
91+
if entry, present := caps.Entry(sourceHAProxy); present {
9292
haproxyAvailable = entry.IsAvailable()
9393
}
9494
}

gearbox/internal/framework/handler/haproxy_config.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,12 @@ func (h *Handler) HAProxyBoxUpdatePost(w http.ResponseWriter, r *http.Request) {
248248
return
249249
}
250250

251+
// The Agent URL or API key may have changed; drop any cached
252+
// capabilities so the next render fetches against the new endpoint
253+
// rather than serving stale data from the previous agent until the
254+
// TTL expires.
255+
h.invalidateBoxCapabilities(server.BoxID)
256+
251257
// Log audit
252258
h.logAudit(r, user.ID, "haproxy_box_update", fmt.Sprintf("Updated HAProxy box: %s (%s)", server.Name, server.BoxID))
253259

@@ -311,6 +317,11 @@ func (h *Handler) HAProxyBoxDeletePost(w http.ResponseWriter, r *http.Request) {
311317
return
312318
}
313319

320+
// Drop cached capabilities — even if the same box ID is recreated
321+
// later, it's likely a different host and we shouldn't serve the
322+
// previous probe table.
323+
h.invalidateBoxCapabilities(server.BoxID)
324+
314325
// Log audit
315326
h.logAudit(r, user.ID, "haproxy_box_delete", fmt.Sprintf("Deleted HAProxy box: %s (%s)", server.Name, server.BoxID))
316327

gearbox/internal/framework/templates/pages/history.templ

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,16 @@ templ History(user *models.User, servers []models.BoxConfig) {
6868
if len(servers) == 0 {
6969
@components.InfoAlert("No servers configured.")
7070
} else {
71-
<!-- No-HAProxy banner — shown when the active box's agent
72-
reports no HAProxy gear. JS toggles visibility based on
73-
/api/{boxID}/capabilities. -->
71+
<!-- HAProxy-unavailable banner — shown when the active box's
72+
agent reports the HAProxy gear is anything other than
73+
'available'. JS in applyCapabilities() picks message
74+
text from the capability entry's status + reason so the
75+
copy matches reality: a missing binary (not_installed)
76+
gets different guidance than an unreachable socket
77+
(inaccessible) or an operator-disabled gear (disabled). -->
7478
<div id="no-haproxy-banner" class="hidden mb-4 rounded-lg border border-blue-200 dark:border-blue-900/40 bg-blue-50 dark:bg-blue-900/20 px-4 py-3 text-sm text-blue-900 dark:text-blue-100">
75-
<strong class="font-semibold">No HAProxy detected on this host.</strong>
76-
Showing host-level metrics only. Install HAProxy or enable the agent's HAProxy gear to see proxy metrics.
79+
<strong id="no-haproxy-banner-title" class="font-semibold">HAProxy metrics unavailable on this host.</strong>
80+
<span id="no-haproxy-banner-detail">Showing host-level metrics only.</span>
7781
</div>
7882

7983
<!-- KPI summary band — populated by loadKPISummary().
@@ -874,13 +878,14 @@ templ History(user *models.User, servers []models.BoxConfig) {
874878
// rather than locking the user out of cards they may need.
875879
async function applyCapabilities(serverID) {
876880
let haproxyAvailable = true;
881+
let haproxyEntry = null;
877882
try {
878883
const res = await fetch('/api/' + serverID + '/capabilities');
879884
if (res.ok) {
880885
const data = await res.json();
881-
const entry = data && data.gears && data.gears.haproxy;
882-
if (entry) {
883-
haproxyAvailable = entry.status === 'available';
886+
haproxyEntry = data && data.gears && data.gears.haproxy;
887+
if (haproxyEntry) {
888+
haproxyAvailable = haproxyEntry.status === 'available';
884889
}
885890
}
886891
} catch (err) {
@@ -896,9 +901,52 @@ templ History(user *models.User, servers []models.BoxConfig) {
896901
const banner = document.getElementById('no-haproxy-banner');
897902
if (banner) {
898903
banner.classList.toggle('hidden', haproxyAvailable);
904+
if (!haproxyAvailable) {
905+
updateNoHAProxyBanner(haproxyEntry);
906+
}
899907
}
900908
}
901909

910+
// updateNoHAProxyBanner picks user-facing copy that matches the
911+
// actual probe verdict — not_installed / inaccessible / disabled
912+
// each get different guidance, and the agent's `reason` field is
913+
// surfaced verbatim when present so operators see the exact
914+
// detection failure.
915+
function updateNoHAProxyBanner(entry) {
916+
const titleEl = document.getElementById('no-haproxy-banner-title');
917+
const detailEl = document.getElementById('no-haproxy-banner-detail');
918+
if (!titleEl || !detailEl) return;
919+
920+
let title = 'HAProxy metrics unavailable on this host.';
921+
let detail = 'Showing host-level metrics only.';
922+
923+
const status = entry && entry.status;
924+
const reason = (entry && entry.reason) ? String(entry.reason) : '';
925+
926+
switch (status) {
927+
case 'not_installed':
928+
title = 'No HAProxy detected on this host.';
929+
detail = 'Install HAProxy or enable the agent\'s HAProxy gear to see proxy metrics. Showing host-level metrics only.';
930+
break;
931+
case 'inaccessible':
932+
title = 'HAProxy is installed but the agent can\'t reach its stats.';
933+
detail = (reason ? reason + ' ' : '') + 'Showing host-level metrics only.';
934+
break;
935+
case 'disabled':
936+
title = 'HAProxy gear disabled in agent configuration.';
937+
detail = (reason ? reason + ' ' : '') + 'Showing host-level metrics only.';
938+
break;
939+
default:
940+
// status === undefined (agent didn't surface haproxy at
941+
// all — older agent or fetch failed). Keep the generic
942+
// copy and don't pretend we know more than we do.
943+
break;
944+
}
945+
946+
titleEl.textContent = title;
947+
detailEl.textContent = ' ' + detail;
948+
}
949+
902950
// hoursToRange maps the hours-select dropdown value (e.g. "0.083",
903951
// "24") to the human-readable range token the new /metrics/* APIs
904952
// accept ("5m", "24h", …). Anything we don't recognise falls back

0 commit comments

Comments
 (0)