Skip to content

Commit 404b695

Browse files
committed
feat: add breakerpool all-down fallback and expose CA status in inspect
When all circuit breakers are open, bypass them and try entries directly instead of returning AllUnavailableError immediately. This fixes the case where a transient outage (e.g., no network) trips all breakers, locking out all CAs for the full 10-minute cooldown even after recovery. Also expose per-CA-endpoint circuit breaker state (healthy/broken) in `epithet agent inspect` output, both human-readable and JSON.
1 parent 8c5f250 commit 404b695

9 files changed

Lines changed: 375 additions & 44 deletions

File tree

cmd/epithet/inspect.go

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,21 @@ func (i *AgentInspectCLI) Run(parent *AgentCLI, logger *slog.Logger) error {
8282
fmt.Printf("Agent Dir: %s\n", resp.AgentSocketDir)
8383
fmt.Printf("Discovery Patterns: %v\n\n", resp.DiscoveryPatterns)
8484

85+
fmt.Printf("CA Endpoints (%d)\n", len(resp.CaEndpoints))
86+
fmt.Printf("-----------------\n")
87+
if len(resp.CaEndpoints) == 0 {
88+
fmt.Printf(" (none)\n")
89+
} else {
90+
for _, ep := range resp.CaEndpoints {
91+
label := "healthy"
92+
if ep.State == "open" || ep.State == "half-open" {
93+
label = "broken"
94+
}
95+
fmt.Printf(" %s (priority %d) — %s\n", ep.Url, ep.Priority, label)
96+
}
97+
}
98+
fmt.Println()
99+
85100
fmt.Printf("Agents (%d)\n", len(resp.Agents))
86101
fmt.Printf("-----------\n")
87102
if len(resp.Agents) == 0 {
@@ -124,11 +139,18 @@ func (i *AgentInspectCLI) Run(parent *AgentCLI, logger *slog.Logger) error {
124139

125140
// inspectResponseJSON is a simplified structure for JSON output.
126141
type inspectResponseJSON struct {
127-
SocketPath string `json:"socketPath"`
128-
AgentSocketDir string `json:"agentSocketDir"`
129-
DiscoveryPatterns []string `json:"discoveryPatterns,omitempty"`
130-
Agents []agentInfoJSON `json:"agents"`
131-
Certificates []certInfoJSON `json:"certificates"`
142+
SocketPath string `json:"socketPath"`
143+
AgentSocketDir string `json:"agentSocketDir"`
144+
DiscoveryPatterns []string `json:"discoveryPatterns,omitempty"`
145+
Agents []agentInfoJSON `json:"agents"`
146+
Certificates []certInfoJSON `json:"certificates"`
147+
CAEndpoints []caEndpointInfoJSON `json:"caEndpoints"`
148+
}
149+
150+
type caEndpointInfoJSON struct {
151+
URL string `json:"url"`
152+
Priority int32 `json:"priority"`
153+
State string `json:"state"`
132154
}
133155

134156
type agentInfoJSON struct {
@@ -168,12 +190,22 @@ func inspectResponseToJSON(resp *pb.InspectResponse) inspectResponseJSON {
168190
}
169191
}
170192

193+
caEndpoints := make([]caEndpointInfoJSON, len(resp.CaEndpoints))
194+
for i, ep := range resp.CaEndpoints {
195+
caEndpoints[i] = caEndpointInfoJSON{
196+
URL: ep.Url,
197+
Priority: ep.Priority,
198+
State: ep.State,
199+
}
200+
}
201+
171202
return inspectResponseJSON{
172203
SocketPath: resp.SocketPath,
173204
AgentSocketDir: resp.AgentSocketDir,
174205
DiscoveryPatterns: resp.DiscoveryPatterns,
175206
Agents: agents,
176207
Certificates: certs,
208+
CAEndpoints: caEndpoints,
177209
}
178210
}
179211

pkg/breakerpool/pool.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,19 @@ func (p *Pool[T, S]) Execute(fn func(S) (T, error)) (T, error) {
175175
// Get next available entry index
176176
idx := p.next()
177177
if idx < 0 {
178+
// All breakers open — try all entries directly, bypassing circuit breakers.
179+
// When every endpoint is marked down, breaker protection has no value
180+
// (nothing to fail over to). This allows recovery from transient outages
181+
// without waiting for the full cooldown.
182+
for _, tier := range p.tiers {
183+
for _, tidx := range tier {
184+
result, err := fn(p.entries[tidx].State)
185+
if err == nil {
186+
return result, nil
187+
}
188+
lastErr = err
189+
}
190+
}
178191
return zero, &AllUnavailableError{LastError: lastErr}
179192
}
180193

@@ -264,6 +277,38 @@ func (p *Pool[T, S]) AllUnavailable() bool {
264277
return true
265278
}
266279

280+
// EndpointStatus represents the current state of a pool entry.
281+
type EndpointStatus[S any] struct {
282+
State S
283+
Priority int
284+
BreakerState string // "closed", "open", "half-open"
285+
}
286+
287+
// Status returns the current state of each entry in priority order.
288+
func (p *Pool[T, S]) Status() []EndpointStatus[S] {
289+
p.mu.Lock()
290+
defer p.mu.Unlock()
291+
292+
statuses := make([]EndpointStatus[S], len(p.entries))
293+
for i, entry := range p.entries {
294+
var state string
295+
switch p.breakers[i].State() {
296+
case gobreaker.StateClosed:
297+
state = "closed"
298+
case gobreaker.StateOpen:
299+
state = "open"
300+
case gobreaker.StateHalfOpen:
301+
state = "half-open"
302+
}
303+
statuses[i] = EndpointStatus[S]{
304+
State: entry.State,
305+
Priority: entry.Priority,
306+
BreakerState: state,
307+
}
308+
}
309+
return statuses
310+
}
311+
267312
// Len returns the number of entries in the pool.
268313
func (p *Pool[T, S]) Len() int {
269314
return len(p.entries)

pkg/breakerpool/pool_test.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,130 @@ func TestPool_PerEntrySettings(t *testing.T) {
305305
require.Len(t, calls, 2)
306306
}
307307

308+
func TestPool_Execute_AllOpenFallbackRecovers(t *testing.T) {
309+
entries := []Entry[testState]{
310+
{State: testState{url: "https://ca1.example.com"}, Priority: 100},
311+
{State: testState{url: "https://ca2.example.com"}, Priority: 50},
312+
}
313+
pool := New[string](entries, testDefaults(time.Minute))
314+
315+
// Trip both breakers.
316+
_, _ = pool.Execute(func(s testState) (string, error) {
317+
return "", &infraError{msg: "down"}
318+
})
319+
require.True(t, pool.AllUnavailable())
320+
321+
// Network recovers — fallback should try directly and succeed.
322+
result, err := pool.Execute(func(s testState) (string, error) {
323+
return "recovered", nil
324+
})
325+
require.NoError(t, err)
326+
require.Equal(t, "recovered", result)
327+
}
328+
329+
func TestPool_Execute_AllOpenFallbackStillFails(t *testing.T) {
330+
entries := []Entry[testState]{
331+
{State: testState{url: "https://ca1.example.com"}, Priority: 100},
332+
{State: testState{url: "https://ca2.example.com"}, Priority: 50},
333+
}
334+
pool := New[string](entries, testDefaults(time.Minute))
335+
336+
// Trip both breakers.
337+
_, _ = pool.Execute(func(s testState) (string, error) {
338+
return "", &infraError{msg: "down"}
339+
})
340+
341+
// Still failing — should return AllUnavailableError.
342+
_, err := pool.Execute(func(s testState) (string, error) {
343+
return "", &infraError{msg: "still down"}
344+
})
345+
require.Error(t, err)
346+
var allUnavail *AllUnavailableError
347+
require.ErrorAs(t, err, &allUnavail)
348+
require.Contains(t, allUnavail.Error(), "still down")
349+
}
350+
351+
func TestPool_Execute_AllOpenFallbackPriorityOrder(t *testing.T) {
352+
entries := []Entry[testState]{
353+
{State: testState{url: "https://ca-low.example.com"}, Priority: 50},
354+
{State: testState{url: "https://ca-high.example.com"}, Priority: 100},
355+
}
356+
pool := New[string](entries, testDefaults(time.Minute))
357+
358+
// Trip both breakers.
359+
_, _ = pool.Execute(func(s testState) (string, error) {
360+
return "", &infraError{msg: "down"}
361+
})
362+
363+
// Track call order in fallback.
364+
var calls []string
365+
_, _ = pool.Execute(func(s testState) (string, error) {
366+
calls = append(calls, s.url)
367+
return "", &infraError{msg: "down"}
368+
})
369+
370+
// Should try high priority first, then low.
371+
require.Equal(t, []string{"https://ca-high.example.com", "https://ca-low.example.com"}, calls)
372+
}
373+
374+
func TestPool_Execute_AllOpenFallbackFirstSuccessWins(t *testing.T) {
375+
entries := []Entry[testState]{
376+
{State: testState{url: "https://ca1.example.com"}, Priority: 100},
377+
{State: testState{url: "https://ca2.example.com"}, Priority: 100},
378+
{State: testState{url: "https://ca3.example.com"}, Priority: 50},
379+
}
380+
pool := New[string](entries, testDefaults(time.Minute))
381+
382+
// Trip all breakers.
383+
for i := 0; i < 3; i++ {
384+
_, _ = pool.Execute(func(s testState) (string, error) {
385+
return "", &infraError{msg: "down"}
386+
})
387+
}
388+
require.True(t, pool.AllUnavailable())
389+
390+
// Second entry succeeds — third should not be tried.
391+
var calls []string
392+
result, err := pool.Execute(func(s testState) (string, error) {
393+
calls = append(calls, s.url)
394+
if s.url == "https://ca2.example.com" {
395+
return "from-ca2", nil
396+
}
397+
return "", &infraError{msg: "down"}
398+
})
399+
require.NoError(t, err)
400+
require.Equal(t, "from-ca2", result)
401+
require.Equal(t, []string{"https://ca1.example.com", "https://ca2.example.com"}, calls)
402+
}
403+
404+
func TestPool_Status(t *testing.T) {
405+
entries := []Entry[testState]{
406+
{State: testState{url: "https://ca1.example.com"}, Priority: 100},
407+
{State: testState{url: "https://ca2.example.com"}, Priority: 50},
408+
}
409+
pool := New[string](entries, testDefaults(time.Minute))
410+
411+
// Both should start closed.
412+
statuses := pool.Status()
413+
require.Len(t, statuses, 2)
414+
require.Equal(t, "closed", statuses[0].BreakerState)
415+
require.Equal(t, "closed", statuses[1].BreakerState)
416+
require.Equal(t, 100, statuses[0].Priority)
417+
require.Equal(t, 50, statuses[1].Priority)
418+
419+
// Trip the high-priority breaker.
420+
_, _ = pool.Execute(func(s testState) (string, error) {
421+
if s.url == "https://ca1.example.com" {
422+
return "", &infraError{msg: "down"}
423+
}
424+
return "ok", nil
425+
})
426+
427+
statuses = pool.Status()
428+
require.Equal(t, "open", statuses[0].BreakerState)
429+
require.Equal(t, "closed", statuses[1].BreakerState)
430+
}
431+
308432
func TestPool_Execute_StringState(t *testing.T) {
309433
// Test with simple string state (common case)
310434
entries := []Entry[string]{

pkg/broker/broker.go

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -190,13 +190,21 @@ type CertInfo struct {
190190
ExpiresAt time.Time `json:"expiresAt"`
191191
}
192192

193+
// CAEndpointInfo contains the current state of a CA endpoint.
194+
type CAEndpointInfo struct {
195+
URL string `json:"url"`
196+
Priority int `json:"priority"`
197+
State string `json:"state"` // "closed", "open", "half-open"
198+
}
199+
193200
// InspectResponse contains the current broker state
194201
type InspectResponse struct {
195-
SocketPath string `json:"socketPath"`
196-
AgentSocketDir string `json:"agentSocketDir"`
197-
DiscoveryPatterns []string `json:"discoveryPatterns,omitempty"` // Fetched live from CA (HTTP cached)
198-
Agents []AgentInfo `json:"agents"`
199-
Certificates []CertInfo `json:"certificates"`
202+
SocketPath string `json:"socketPath"`
203+
AgentSocketDir string `json:"agentSocketDir"`
204+
DiscoveryPatterns []string `json:"discoveryPatterns,omitempty"` // Fetched live from CA (HTTP cached)
205+
Agents []AgentInfo `json:"agents"`
206+
Certificates []CertInfo `json:"certificates"`
207+
CAEndpoints []CAEndpointInfo `json:"caEndpoints"`
200208
}
201209

202210
// Match is invoked via rpc from `epithet match` invocations.
@@ -701,5 +709,14 @@ func (b *Broker) Inspect(_ InspectRequest, output *InspectResponse) error {
701709
// Get certificate info
702710
output.Certificates = b.certStore.List()
703711

712+
// Get CA endpoint status
713+
for _, ep := range b.caClient.EndpointStatus() {
714+
output.CAEndpoints = append(output.CAEndpoints, CAEndpointInfo{
715+
URL: ep.URL,
716+
Priority: ep.Priority,
717+
State: ep.BreakerState,
718+
})
719+
}
720+
704721
return nil
705722
}

pkg/broker/grpc_server.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,12 +104,22 @@ func inspectResponseToProto(resp *InspectResponse) *pb.InspectResponse {
104104
}
105105
}
106106

107+
caEndpoints := make([]*pb.CAEndpointInfo, len(resp.CAEndpoints))
108+
for i, ep := range resp.CAEndpoints {
109+
caEndpoints[i] = &pb.CAEndpointInfo{
110+
Url: ep.URL,
111+
Priority: int32(ep.Priority),
112+
State: ep.State,
113+
}
114+
}
115+
107116
return &pb.InspectResponse{
108117
SocketPath: resp.SocketPath,
109118
AgentSocketDir: resp.AgentSocketDir,
110119
DiscoveryPatterns: resp.DiscoveryPatterns,
111120
Agents: agents,
112121
Certificates: certs,
122+
CaEndpoints: caEndpoints,
113123
}
114124
}
115125

0 commit comments

Comments
 (0)