Skip to content

Commit a57b409

Browse files
committed
feat: implement DumpState for file-discovery
Expose the set of endpoints currently loaded from the discovery file through /debug/plugins/state. Only the endpoint identities are reported, sorted and capped so the payload stays bounded; addresses and labels are left out. DumpState reads the endpoint set concurrently with file reloads, so a read-write mutex now guards it and load swaps the set under the lock. Part of #1755. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
1 parent 1fa3803 commit a57b409

2 files changed

Lines changed: 127 additions & 3 deletions

File tree

pkg/epp/framework/plugins/datalayer/discovery/file/plugin.go

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"io"
2727
"net"
2828
"os"
29+
"sort"
2930
"strconv"
3031
"sync"
3132

@@ -71,6 +72,8 @@ type FileDiscovery struct {
7172
typedName fwkplugin.TypedName
7273
path string
7374
watchFile bool
75+
// mu guards endpoints, which DumpState reads concurrently with load.
76+
mu sync.RWMutex
7477
// endpoints is the set of endpoint identities applied to the datastore
7578
// from the last successful load. Used as a key set only -- values are
7679
// zero-byte structs. Compared against the entries parsed during a
@@ -81,7 +84,10 @@ type FileDiscovery struct {
8184
readyOnce sync.Once
8285
}
8386

84-
var _ fwkdl.EndpointDiscovery = (*FileDiscovery)(nil)
87+
var (
88+
_ fwkdl.EndpointDiscovery = (*FileDiscovery)(nil)
89+
_ fwkplugin.StateDumper = (*FileDiscovery)(nil)
90+
)
8591

8692
// Factory is the plugin factory for file-discovery.
8793
func Factory(name string, parameters *json.Decoder, _ fwkplugin.Handle) (fwkplugin.Plugin, error) {
@@ -108,6 +114,40 @@ func Factory(name string, parameters *json.Decoder, _ fwkplugin.Handle) (fwkplug
108114

109115
func (f *FileDiscovery) TypedName() fwkplugin.TypedName { return f.typedName }
110116

117+
const maxDebugDumpEndpoints = 100
118+
119+
// discoveryState is the sanitized snapshot returned by DumpState: discovered
120+
// endpoint identities only, never their addresses or labels. The dump is partial
121+
// when TotalEndpoints exceeds MaxEndpoints.
122+
type discoveryState struct {
123+
Endpoints []string `json:"endpoints"`
124+
TotalEndpoints int `json:"totalEndpoints"`
125+
MaxEndpoints int `json:"maxEndpoints"`
126+
}
127+
128+
// DumpState reports the endpoint identities currently loaded from the file,
129+
// sorted and capped to maxDebugDumpEndpoints so the payload stays bounded. The
130+
// set is snapshotted under a read lock, so a concurrent reload may not yet be
131+
// reflected; best-effort visibility is enough for a debug endpoint.
132+
func (f *FileDiscovery) DumpState() (json.RawMessage, error) {
133+
f.mu.RLock()
134+
names := make([]string, 0, len(f.endpoints))
135+
for id := range f.endpoints {
136+
names = append(names, id.String())
137+
}
138+
f.mu.RUnlock()
139+
140+
total := len(names)
141+
sort.Strings(names)
142+
143+
state := discoveryState{TotalEndpoints: total, MaxEndpoints: maxDebugDumpEndpoints}
144+
if len(names) > maxDebugDumpEndpoints {
145+
names = names[:maxDebugDumpEndpoints]
146+
}
147+
state.Endpoints = names
148+
return json.Marshal(state)
149+
}
150+
111151
// Ready returns a channel closed after the first successful load of the
112152
// endpoints file. See EndpointDiscovery.Ready for the contract.
113153
func (f *FileDiscovery) Ready() <-chan struct{} { return f.ready }
@@ -222,11 +262,15 @@ func (f *FileDiscovery) load(notifier fwkdl.DiscoveryNotifier) error {
222262
notifier.Upsert(meta)
223263
}
224264

225-
for id := range f.endpoints {
265+
f.mu.Lock()
266+
old := f.endpoints
267+
f.endpoints = incoming
268+
f.mu.Unlock()
269+
270+
for id := range old {
226271
if _, ok := incoming[id]; !ok {
227272
notifier.Delete(id)
228273
}
229274
}
230-
f.endpoints = incoming
231275
return errors.Join(errs...)
232276
}

pkg/epp/framework/plugins/datalayer/discovery/file/plugin_test.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package file
1919
import (
2020
"context"
2121
"encoding/json"
22+
"fmt"
2223
"os"
2324
"strings"
2425
"sync"
@@ -292,3 +293,82 @@ endpoints:
292293
cancel()
293294
assert.NoError(t, <-done)
294295
}
296+
297+
func TestDumpState(t *testing.T) {
298+
f := &FileDiscovery{
299+
endpoints: map[types.NamespacedName]struct{}{
300+
{Namespace: "default", Name: "pod-b"}: {},
301+
{Namespace: "default", Name: "pod-a"}: {},
302+
},
303+
}
304+
305+
payload, err := f.DumpState()
306+
require.NoError(t, err)
307+
308+
var state discoveryState
309+
require.NoError(t, json.Unmarshal(payload, &state))
310+
assert.Equal(t, []string{"default/pod-a", "default/pod-b"}, state.Endpoints)
311+
assert.Equal(t, 2, state.TotalEndpoints)
312+
assert.Equal(t, maxDebugDumpEndpoints, state.MaxEndpoints)
313+
// The full set fits, so the dump is complete (TotalEndpoints does not exceed MaxEndpoints).
314+
assert.LessOrEqual(t, state.TotalEndpoints, state.MaxEndpoints)
315+
}
316+
317+
func TestDumpStateCaps(t *testing.T) {
318+
eps := make(map[types.NamespacedName]struct{}, maxDebugDumpEndpoints+5)
319+
for i := 0; i < maxDebugDumpEndpoints+5; i++ {
320+
eps[types.NamespacedName{Namespace: "default", Name: fmt.Sprintf("pod-%03d", i)}] = struct{}{}
321+
}
322+
f := &FileDiscovery{endpoints: eps}
323+
324+
payload, err := f.DumpState()
325+
require.NoError(t, err)
326+
327+
var state discoveryState
328+
require.NoError(t, json.Unmarshal(payload, &state))
329+
// The dump is partial: TotalEndpoints exceeds the returned count, capped at MaxEndpoints.
330+
assert.Equal(t, maxDebugDumpEndpoints+5, state.TotalEndpoints)
331+
assert.Greater(t, state.TotalEndpoints, state.MaxEndpoints)
332+
assert.Len(t, state.Endpoints, maxDebugDumpEndpoints)
333+
// Sorted ascending, then capped, so the first maxDebugDumpEndpoints names are kept.
334+
assert.Equal(t, "default/pod-000", state.Endpoints[0])
335+
assert.Equal(t, fmt.Sprintf("default/pod-%03d", maxDebugDumpEndpoints-1), state.Endpoints[maxDebugDumpEndpoints-1])
336+
}
337+
338+
func TestDumpStateConcurrentWithLoad(t *testing.T) {
339+
path := writeTemp(t, "endpoints:\n- name: ep1\n address: 10.0.0.1\n port: \"8000\"\n")
340+
f := &FileDiscovery{path: path, endpoints: map[types.NamespacedName]struct{}{}}
341+
notifier := &recordingNotifier{}
342+
343+
var wg sync.WaitGroup
344+
wg.Add(2)
345+
go func() {
346+
defer wg.Done()
347+
for i := 0; i < 100; i++ {
348+
_ = f.load(notifier)
349+
}
350+
}()
351+
go func() {
352+
defer wg.Done()
353+
for i := 0; i < 100; i++ {
354+
if _, err := f.DumpState(); err != nil {
355+
t.Errorf("DumpState returned error: %v", err)
356+
}
357+
}
358+
}()
359+
wg.Wait()
360+
}
361+
362+
func TestDumpStateEmpty(t *testing.T) {
363+
f := &FileDiscovery{endpoints: map[types.NamespacedName]struct{}{}}
364+
365+
payload, err := f.DumpState()
366+
require.NoError(t, err)
367+
assert.True(t, json.Valid(payload))
368+
369+
var state discoveryState
370+
require.NoError(t, json.Unmarshal(payload, &state))
371+
assert.Empty(t, state.Endpoints)
372+
assert.Equal(t, 0, state.TotalEndpoints)
373+
assert.Equal(t, maxDebugDumpEndpoints, state.MaxEndpoints)
374+
}

0 commit comments

Comments
 (0)