-
Notifications
You must be signed in to change notification settings - Fork 0
feat(#91): metrics gear — source-aware, no-HAProxy mode (phases 0-2) #93
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 2 commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
|
|
||
| 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
192
gearbox/internal/framework/agent/capabilities_cache_test.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.