Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ gearbox-agent/data/
# Environment
gearbox/.env
gearbox-agent/.env
.local.config
*.local.json
.mcp.json

Expand Down
5 changes: 5 additions & 0 deletions gearbox/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,11 @@ func main() {
r.Get("/{boxID}/metrics/backend/{backendName}/details", h.APIMetricsBackendDetailsHandler)
r.Get("/{boxID}/metrics/log-errors", h.APIMetricsLogErrorsHandler)

// Per-box capability manifest — exposes which agent gears probed
// available so the metrics gear (and future source-aware UI) can
// hide cards/KPIs that don't apply to this host.
r.Get("/{boxID}/capabilities", h.APIBoxCapabilitiesHandler)

// Disabled entities management
r.Get("/{boxID}/disabled-entities", h.APIDisabledEntitiesHandler)
r.Post("/{boxID}/disable-entity", h.APIDisableEntityHandler)
Expand Down
147 changes: 147 additions & 0 deletions gearbox/internal/framework/agent/capabilities_cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package agent

import (
"sync"
"time"
)

// BoxCapabilities is a dashboard-side view of one box's probe table.
// Carries the CapabilitiesResponse from the agent plus when it was last
// observed. Handlers consume this to gate UI on what the agent reports
// the box can do — e.g. hide HAProxy charts when the haproxy gear isn't
// available on this host.
type BoxCapabilities struct {
Response *CapabilitiesResponse
FetchedAt time.Time
}

// Has reports whether the agent surfaced the given gear at all, regardless
// of probe verdict. Use when "the agent is recent enough to know about
// this gear" is the question — older agents that pre-date a gear simply
// won't have it in their probe table.
func (b *BoxCapabilities) Has(gearName string) bool {
if b == nil || b.Response == nil {
return false
}
_, ok := b.Response.Gears[gearName]
return ok
}

// IsAvailable reports whether the gear probed Available on the agent.
// Returns false when capabilities are nil/unknown; callers wanting
// fail-open behaviour should check Has() separately.
func (b *BoxCapabilities) IsAvailable(gearName string) bool {
if b == nil || b.Response == nil {
return false
}
e, ok := b.Response.Gears[gearName]
return ok && e.IsAvailable()
}

// Entry returns the agent's probe entry for the gear if it's present in
// the response. The second return is false when the gear isn't surfaced
// or capabilities are nil.
func (b *BoxCapabilities) Entry(gearName string) (CapabilityEntry, bool) {
if b == nil || b.Response == nil {
return CapabilityEntry{}, false
}
e, ok := b.Response.Gears[gearName]
return e, ok
}

// CapabilitiesCache memoises agent capability fetches per box. Dashboard
// pages call into this on every render that needs to decide what to
// surface; without the cache, that's one synchronous round-trip per
// render. TTL is short so a recently-restarted agent's new probe table
// shows up without operator intervention; reconnect events should
// invalidate explicitly via Invalidate() for an immediate refresh.
//
// Negative results (fetch errors, agent dead) are also cached for the
// TTL window so a flaky or unreachable agent doesn't drag down every
// page render with a fresh failed call.
type CapabilitiesCache struct {
mu sync.RWMutex
entries map[string]*cachedCapabilities
ttl time.Duration
fetchTimeout time.Duration
}

type cachedCapabilities struct {
caps *BoxCapabilities // nil when the last fetch errored
err error // captured on failure for callers that want it
fetchedAt time.Time
}

// NewCapabilitiesCache creates a cache with the given TTL and per-fetch
// timeout. The timeout must be short — capability fetches sit on the
// critical path of page renders, so a dead agent must not stall the UI.
// 3s matches the value the Gears settings page used pre-cache.
func NewCapabilitiesCache(ttl, fetchTimeout time.Duration) *CapabilitiesCache {
return &CapabilitiesCache{
entries: make(map[string]*cachedCapabilities),
ttl: ttl,
fetchTimeout: fetchTimeout,
}
}

// Get returns the cached capabilities for boxID. If the entry is missing
// or older than the TTL, the cache fetches fresh capabilities from
// agentURL using the supplied API key. On fetch error, returns (nil, err);
// the error is also cached for the TTL window. Callers should treat
// (nil, err) as "unknown" and fail open — locking users out of pages
// because the agent is briefly unreachable is worse than briefly showing
// gears that may not be available.
func (c *CapabilitiesCache) Get(boxID, agentURL, apiKey string) (*BoxCapabilities, error) {
if entry, ok := c.lookup(boxID); ok {
return entry.caps, entry.err
Comment thread
sarg3nt marked this conversation as resolved.
}

client := NewClientWithTimeout(agentURL, apiKey, c.fetchTimeout)
resp, err := client.GetCapabilities()

now := time.Now()
var caps *BoxCapabilities
if err == nil {
caps = &BoxCapabilities{Response: resp, FetchedAt: now}
}

c.mu.Lock()
c.entries[boxID] = &cachedCapabilities{caps: caps, err: err, fetchedAt: now}
c.mu.Unlock()

return caps, err
}

// lookup returns a non-stale entry under the read lock, or (nil, false)
// if missing/expired. Split out so Get's refresh path can drop the read
// lock before doing network I/O.
func (c *CapabilitiesCache) lookup(boxID string) (*cachedCapabilities, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
entry, ok := c.entries[boxID]
if !ok {
return nil, false
}
if time.Since(entry.fetchedAt) >= c.ttl {
return nil, false
}
return entry, true
}

// Invalidate drops the cached entry for boxID, forcing a fresh fetch on
// the next Get. Call this when the box reconnects so a restarted agent's
// new probe table is picked up immediately rather than at the next TTL
// boundary.
func (c *CapabilitiesCache) Invalidate(boxID string) {
c.mu.Lock()
delete(c.entries, boxID)
c.mu.Unlock()
}

// InvalidateAll drops every cached entry. Useful when global config that
// affects probing changes (rare) and in tests.
func (c *CapabilitiesCache) InvalidateAll() {
c.mu.Lock()
c.entries = make(map[string]*cachedCapabilities)
c.mu.Unlock()
}
192 changes: 192 additions & 0 deletions gearbox/internal/framework/agent/capabilities_cache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
package agent

import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)

// newCapabilitiesServer is a small httptest server that returns a
// CapabilitiesResponse and tracks how many times it was called.
func newCapabilitiesServer(t *testing.T, response CapabilitiesResponse) (*httptest.Server, *atomic.Int64) {
t.Helper()
var hits atomic.Int64
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
if r.URL.Path != "/api/v1/system/capabilities" {
t.Errorf("unexpected path %q", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response)
}))
return srv, &hits
}

func TestCapabilitiesCacheCachesWithinTTL(t *testing.T) {
srv, hits := newCapabilitiesServer(t, CapabilitiesResponse{
Gears: map[string]CapabilityEntry{
"haproxy": {Status: "available"},
},
})
defer srv.Close()

cache := NewCapabilitiesCache(5*time.Minute, 2*time.Second)

for i := 0; i < 3; i++ {
caps, err := cache.Get("box-1", srv.URL, "test-key")
if err != nil {
t.Fatalf("Get %d: %v", i, err)
}
if !caps.IsAvailable("haproxy") {
t.Fatalf("Get %d: haproxy should be available", i)
}
}

if got := hits.Load(); got != 1 {
t.Errorf("expected 1 upstream hit, got %d", got)
}
}

func TestCapabilitiesCacheRefreshesAfterTTL(t *testing.T) {
srv, hits := newCapabilitiesServer(t, CapabilitiesResponse{
Gears: map[string]CapabilityEntry{"haproxy": {Status: "available"}},
})
defer srv.Close()

// Sub-millisecond TTL: every Get triggers a refresh.
cache := NewCapabilitiesCache(1*time.Nanosecond, 2*time.Second)

for i := 0; i < 3; i++ {
if _, err := cache.Get("box-1", srv.URL, "test-key"); err != nil {
t.Fatalf("Get %d: %v", i, err)
}
}

if got := hits.Load(); got != 3 {
t.Errorf("expected 3 upstream hits (each past TTL), got %d", got)
}
}

func TestCapabilitiesCacheInvalidateForcesRefresh(t *testing.T) {
srv, hits := newCapabilitiesServer(t, CapabilitiesResponse{
Gears: map[string]CapabilityEntry{"haproxy": {Status: "available"}},
})
defer srv.Close()

cache := NewCapabilitiesCache(5*time.Minute, 2*time.Second)

if _, err := cache.Get("box-1", srv.URL, "test-key"); err != nil {
t.Fatalf("initial Get: %v", err)
}
cache.Invalidate("box-1")
if _, err := cache.Get("box-1", srv.URL, "test-key"); err != nil {
t.Fatalf("post-invalidate Get: %v", err)
}

if got := hits.Load(); got != 2 {
t.Errorf("expected 2 upstream hits (invalidate + refresh), got %d", got)
}
}

func TestCapabilitiesCacheNegativeCaching(t *testing.T) {
var hits atomic.Int64
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hits.Add(1)
http.Error(w, "agent unavailable", http.StatusServiceUnavailable)
}))
defer srv.Close()

cache := NewCapabilitiesCache(5*time.Minute, 2*time.Second)

for i := 0; i < 3; i++ {
caps, err := cache.Get("box-1", srv.URL, "test-key")
if err == nil {
t.Fatalf("Get %d: expected error from failing agent", i)
}
if caps != nil {
t.Fatalf("Get %d: expected nil caps on error, got %+v", i, caps)
}
}

if got := hits.Load(); got != 1 {
t.Errorf("expected 1 upstream hit (negative cache), got %d", got)
}
}

func TestBoxCapabilitiesNilSafe(t *testing.T) {
var b *BoxCapabilities
if b.Has("haproxy") {
t.Error("nil Has should return false")
}
if b.IsAvailable("haproxy") {
t.Error("nil IsAvailable should return false")
}
if _, ok := b.Entry("haproxy"); ok {
t.Error("nil Entry should return false")
}
}

func TestBoxCapabilitiesAccessors(t *testing.T) {
b := &BoxCapabilities{
Response: &CapabilitiesResponse{
Gears: map[string]CapabilityEntry{
"haproxy": {Status: "available"},
"metrics": {Status: "not_installed"},
},
},
FetchedAt: time.Now(),
}

if !b.Has("haproxy") {
t.Error("Has(haproxy) should be true")
}
if b.Has("nginx") {
t.Error("Has(nginx) should be false (not in response)")
}
if !b.IsAvailable("haproxy") {
t.Error("IsAvailable(haproxy) should be true")
}
if b.IsAvailable("metrics") {
t.Error("IsAvailable(metrics) should be false (not_installed)")
}
if b.IsAvailable("nginx") {
t.Error("IsAvailable(nginx) should be false (absent)")
}

entry, ok := b.Entry("metrics")
if !ok || entry.Status != "not_installed" {
t.Errorf("Entry(metrics) = %+v, ok=%v; want not_installed", entry, ok)
}
}

func TestCapabilitiesCacheInvalidateAll(t *testing.T) {
srv, hits := newCapabilitiesServer(t, CapabilitiesResponse{
Gears: map[string]CapabilityEntry{"haproxy": {Status: "available"}},
})
defer srv.Close()

cache := NewCapabilitiesCache(5*time.Minute, 2*time.Second)

if _, err := cache.Get("box-1", srv.URL, "test-key"); err != nil {
t.Fatalf("box-1 Get: %v", err)
}
if _, err := cache.Get("box-2", srv.URL, "test-key"); err != nil {
t.Fatalf("box-2 Get: %v", err)
}

cache.InvalidateAll()

if _, err := cache.Get("box-1", srv.URL, "test-key"); err != nil {
t.Fatalf("post-invalidate box-1: %v", err)
}
if _, err := cache.Get("box-2", srv.URL, "test-key"); err != nil {
t.Fatalf("post-invalidate box-2: %v", err)
}

if got := hits.Load(); got != 4 {
t.Errorf("expected 4 hits (2 initial + 2 post-invalidate), got %d", got)
}
}
Loading
Loading