Skip to content

Commit eb40457

Browse files
rgarciacursoragent
andauthored
feat: add metadata and state filtering to GET /instances (#103)
Add query parameters to the list instances endpoint so callers can filter by instance state and/or user-defined metadata key-value pairs: GET /instances?state=Running&metadata[team]=backend&metadata[env]=staging Uses OpenAPI deepObject style for the metadata parameter, which oapi-codegen deserializes into map[string]string automatically. Multiple metadata entries are ANDed -- an instance must match all specified key-value pairs. Adds ListInstancesFilter type with a Matches() method, updates the Manager interface to accept *ListInstancesFilter, and wires it through the API handler. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 2465caa commit eb40457

11 files changed

Lines changed: 604 additions & 192 deletions

File tree

cmd/api/api/instances.go

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,24 @@ import (
2121
"github.com/samber/lo"
2222
)
2323

24-
// ListInstances lists all instances
24+
// ListInstances lists instances, optionally filtered by state and/or metadata.
2525
func (s *ApiService) ListInstances(ctx context.Context, request oapi.ListInstancesRequestObject) (oapi.ListInstancesResponseObject, error) {
2626
log := logger.FromContext(ctx)
2727

28-
domainInsts, err := s.InstanceManager.ListInstances(ctx)
28+
// Convert OAPI params to domain filter
29+
var filter *instances.ListInstancesFilter
30+
if request.Params.State != nil || request.Params.Metadata != nil {
31+
filter = &instances.ListInstancesFilter{}
32+
if request.Params.State != nil {
33+
state := instances.State(*request.Params.State)
34+
filter.State = &state
35+
}
36+
if request.Params.Metadata != nil {
37+
filter.Metadata = *request.Params.Metadata
38+
}
39+
}
40+
41+
domainInsts, err := s.InstanceManager.ListInstances(ctx, filter)
2942
if err != nil {
3043
log.ErrorContext(ctx, "failed to list instances", "error", err)
3144
return oapi.ListInstances500JSONResponse{

cmd/api/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ func run() error {
171171
// Include Unknown state: we couldn't confirm their state, but they might still
172172
// have a running VMM. Better to leave a stale TAP than crash a running VM.
173173
var preserveTAPs []string
174-
allInstances, err := app.InstanceManager.ListInstances(app.Ctx)
174+
allInstances, err := app.InstanceManager.ListInstances(app.Ctx, nil)
175175
if err != nil {
176176
// On error, skip TAP cleanup entirely to avoid crashing running VMs.
177177
// Pass nil to Initialize to skip cleanup.

lib/builds/manager_test.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,12 @@ func newMockInstanceManager() *mockInstanceManager {
3737
}
3838
}
3939

40-
func (m *mockInstanceManager) ListInstances(ctx context.Context) ([]instances.Instance, error) {
40+
func (m *mockInstanceManager) ListInstances(ctx context.Context, filter *instances.ListInstancesFilter) ([]instances.Instance, error) {
4141
var result []instances.Instance
4242
for _, inst := range m.instances {
43-
result = append(result, *inst)
43+
if filter == nil || filter.Matches(inst) {
44+
result = append(result, *inst)
45+
}
4446
}
4547
return result, nil
4648
}

lib/instances/filter_test.go

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
package instances
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"os"
7+
"path/filepath"
8+
"testing"
9+
"time"
10+
11+
"github.com/kernel/hypeman/lib/hypervisor"
12+
"github.com/kernel/hypeman/lib/paths"
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
func TestListInstancesFilter_Matches(t *testing.T) {
18+
running := StateRunning
19+
stopped := StateStopped
20+
21+
inst := &Instance{
22+
StoredMetadata: StoredMetadata{
23+
Id: "inst-1",
24+
Name: "web-server",
25+
Image: "nginx:latest",
26+
Metadata: map[string]string{
27+
"team": "backend",
28+
"env": "staging",
29+
},
30+
},
31+
State: StateRunning,
32+
}
33+
34+
tests := []struct {
35+
name string
36+
filter *ListInstancesFilter
37+
want bool
38+
}{
39+
{
40+
name: "nil filter matches everything",
41+
filter: nil,
42+
want: true,
43+
},
44+
{
45+
name: "empty filter matches everything",
46+
filter: &ListInstancesFilter{},
47+
want: true,
48+
},
49+
{
50+
name: "state filter matches",
51+
filter: &ListInstancesFilter{State: &running},
52+
want: true,
53+
},
54+
{
55+
name: "state filter does not match",
56+
filter: &ListInstancesFilter{State: &stopped},
57+
want: false,
58+
},
59+
{
60+
name: "single metadata key matches",
61+
filter: &ListInstancesFilter{
62+
Metadata: map[string]string{"team": "backend"},
63+
},
64+
want: true,
65+
},
66+
{
67+
name: "single metadata key wrong value",
68+
filter: &ListInstancesFilter{
69+
Metadata: map[string]string{"team": "frontend"},
70+
},
71+
want: false,
72+
},
73+
{
74+
name: "metadata key does not exist",
75+
filter: &ListInstancesFilter{
76+
Metadata: map[string]string{"project": "alpha"},
77+
},
78+
want: false,
79+
},
80+
{
81+
name: "multiple metadata keys all match",
82+
filter: &ListInstancesFilter{
83+
Metadata: map[string]string{
84+
"team": "backend",
85+
"env": "staging",
86+
},
87+
},
88+
want: true,
89+
},
90+
{
91+
name: "multiple metadata keys partial match",
92+
filter: &ListInstancesFilter{
93+
Metadata: map[string]string{
94+
"team": "backend",
95+
"env": "production",
96+
},
97+
},
98+
want: false,
99+
},
100+
{
101+
name: "state and metadata combined match",
102+
filter: &ListInstancesFilter{
103+
State: &running,
104+
Metadata: map[string]string{"team": "backend"},
105+
},
106+
want: true,
107+
},
108+
{
109+
name: "state matches but metadata does not",
110+
filter: &ListInstancesFilter{
111+
State: &running,
112+
Metadata: map[string]string{"team": "frontend"},
113+
},
114+
want: false,
115+
},
116+
{
117+
name: "metadata matches but state does not",
118+
filter: &ListInstancesFilter{
119+
State: &stopped,
120+
Metadata: map[string]string{"team": "backend"},
121+
},
122+
want: false,
123+
},
124+
}
125+
126+
for _, tc := range tests {
127+
t.Run(tc.name, func(t *testing.T) {
128+
got := tc.filter.Matches(inst)
129+
assert.Equal(t, tc.want, got)
130+
})
131+
}
132+
}
133+
134+
func TestListInstancesFilter_Matches_NilMetadata(t *testing.T) {
135+
inst := &Instance{
136+
StoredMetadata: StoredMetadata{
137+
Id: "inst-2",
138+
Metadata: nil,
139+
},
140+
State: StateRunning,
141+
}
142+
143+
filter := &ListInstancesFilter{
144+
Metadata: map[string]string{"team": "backend"},
145+
}
146+
assert.False(t, filter.Matches(inst), "should not match when instance has no metadata")
147+
}
148+
149+
// TestListInstances_WithFilter exercises the full ListInstances path using
150+
// on-disk metadata files (no KVM required).
151+
func TestListInstances_WithFilter(t *testing.T) {
152+
tmpDir := t.TempDir()
153+
p := paths.New(tmpDir)
154+
155+
mgr := &manager{paths: p}
156+
157+
// Create three instances with different metadata on disk
158+
instances := []StoredMetadata{
159+
{
160+
Id: "inst-a",
161+
Name: "web",
162+
Image: "nginx:latest",
163+
Metadata: map[string]string{"team": "backend", "env": "prod"},
164+
CreatedAt: time.Now(),
165+
HypervisorType: hypervisor.TypeCloudHypervisor,
166+
SocketPath: "/nonexistent/a.sock",
167+
DataDir: p.InstanceDir("inst-a"),
168+
},
169+
{
170+
Id: "inst-b",
171+
Name: "worker",
172+
Image: "python:3",
173+
Metadata: map[string]string{"team": "backend", "env": "staging"},
174+
CreatedAt: time.Now(),
175+
HypervisorType: hypervisor.TypeCloudHypervisor,
176+
SocketPath: "/nonexistent/b.sock",
177+
DataDir: p.InstanceDir("inst-b"),
178+
},
179+
{
180+
Id: "inst-c",
181+
Name: "frontend",
182+
Image: "node:20",
183+
Metadata: map[string]string{"team": "frontend", "env": "prod"},
184+
CreatedAt: time.Now(),
185+
HypervisorType: hypervisor.TypeCloudHypervisor,
186+
SocketPath: "/nonexistent/c.sock",
187+
DataDir: p.InstanceDir("inst-c"),
188+
},
189+
}
190+
191+
for _, stored := range instances {
192+
require.NoError(t, mgr.ensureDirectories(stored.Id))
193+
data, err := json.MarshalIndent(&metadata{StoredMetadata: stored}, "", " ")
194+
require.NoError(t, err)
195+
require.NoError(t, os.WriteFile(filepath.Join(p.InstanceDir(stored.Id), "metadata.json"), data, 0644))
196+
}
197+
198+
ctx := context.Background()
199+
200+
t.Run("no filter returns all", func(t *testing.T) {
201+
result, err := mgr.ListInstances(ctx, nil)
202+
require.NoError(t, err)
203+
assert.Len(t, result, 3)
204+
})
205+
206+
t.Run("filter by single metadata key", func(t *testing.T) {
207+
result, err := mgr.ListInstances(ctx, &ListInstancesFilter{
208+
Metadata: map[string]string{"team": "backend"},
209+
})
210+
require.NoError(t, err)
211+
assert.Len(t, result, 2)
212+
names := []string{result[0].Name, result[1].Name}
213+
assert.ElementsMatch(t, []string{"web", "worker"}, names)
214+
})
215+
216+
t.Run("filter by two metadata keys", func(t *testing.T) {
217+
result, err := mgr.ListInstances(ctx, &ListInstancesFilter{
218+
Metadata: map[string]string{"team": "backend", "env": "prod"},
219+
})
220+
require.NoError(t, err)
221+
require.Len(t, result, 1)
222+
assert.Equal(t, "web", result[0].Name)
223+
})
224+
225+
t.Run("filter by metadata with no matches", func(t *testing.T) {
226+
result, err := mgr.ListInstances(ctx, &ListInstancesFilter{
227+
Metadata: map[string]string{"team": "devops"},
228+
})
229+
require.NoError(t, err)
230+
assert.Empty(t, result)
231+
})
232+
233+
t.Run("filter by state", func(t *testing.T) {
234+
// All instances have no socket so they're Stopped
235+
stopped := StateStopped
236+
result, err := mgr.ListInstances(ctx, &ListInstancesFilter{
237+
State: &stopped,
238+
})
239+
require.NoError(t, err)
240+
assert.Len(t, result, 3)
241+
242+
running := StateRunning
243+
result, err = mgr.ListInstances(ctx, &ListInstancesFilter{
244+
State: &running,
245+
})
246+
require.NoError(t, err)
247+
assert.Empty(t, result)
248+
})
249+
250+
t.Run("filter by state and metadata combined", func(t *testing.T) {
251+
stopped := StateStopped
252+
result, err := mgr.ListInstances(ctx, &ListInstancesFilter{
253+
State: &stopped,
254+
Metadata: map[string]string{"env": "prod"},
255+
})
256+
require.NoError(t, err)
257+
assert.Len(t, result, 2)
258+
names := []string{result[0].Name, result[1].Name}
259+
assert.ElementsMatch(t, []string{"web", "frontend"}, names)
260+
})
261+
}

lib/instances/manager.go

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import (
1818
)
1919

2020
type Manager interface {
21-
ListInstances(ctx context.Context) ([]Instance, error)
21+
ListInstances(ctx context.Context, filter *ListInstancesFilter) ([]Instance, error)
2222
CreateInstance(ctx context.Context, req CreateInstanceRequest) (*Instance, error)
2323
// GetInstance returns an instance by ID, name, or ID prefix.
2424
// Lookup order: exact ID match -> exact name match -> ID prefix match.
@@ -214,11 +214,25 @@ func (m *manager) StartInstance(ctx context.Context, id string, req StartInstanc
214214
return m.startInstance(ctx, id, req)
215215
}
216216

217-
// ListInstances returns all instances
218-
func (m *manager) ListInstances(ctx context.Context) ([]Instance, error) {
217+
// ListInstances returns instances, optionally filtered by the given criteria.
218+
// Pass nil to return all instances.
219+
func (m *manager) ListInstances(ctx context.Context, filter *ListInstancesFilter) ([]Instance, error) {
219220
// No lock - eventual consistency is acceptable for list operations.
220221
// State is derived dynamically, so list is always reasonably current.
221-
return m.listInstances(ctx)
222+
all, err := m.listInstances(ctx)
223+
if err != nil {
224+
return nil, err
225+
}
226+
if filter == nil {
227+
return all, nil
228+
}
229+
filtered := make([]Instance, 0, len(all))
230+
for i := range all {
231+
if filter.Matches(&all[i]) {
232+
filtered = append(filtered, all[i])
233+
}
234+
}
235+
return filtered, nil
222236
}
223237

224238
// GetInstance returns an instance by ID, name, or ID prefix.
@@ -240,7 +254,7 @@ func (m *manager) GetInstance(ctx context.Context, idOrName string) (*Instance,
240254
}
241255

242256
// 2. List all instances for name and prefix matching
243-
instances, err := m.ListInstances(ctx)
257+
instances, err := m.ListInstances(ctx, nil)
244258
if err != nil {
245259
return nil, err
246260
}

lib/instances/manager_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,7 @@ func TestBasicEndToEnd(t *testing.T) {
321321
assert.Equal(t, StateRunning, retrieved.State)
322322

323323
// List instances
324-
instances, err := manager.ListInstances(ctx)
324+
instances, err := manager.ListInstances(ctx, nil)
325325
require.NoError(t, err)
326326
assert.Len(t, instances, 1)
327327
assert.Equal(t, inst.Id, instances[0].Id)

lib/instances/qemu_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ func TestQEMUBasicEndToEnd(t *testing.T) {
316316
assert.Equal(t, StateRunning, retrieved.State)
317317

318318
// List instances
319-
instances, err := manager.ListInstances(ctx)
319+
instances, err := manager.ListInstances(ctx, nil)
320320
require.NoError(t, err)
321321
assert.Len(t, instances, 1)
322322
assert.Equal(t, inst.Id, instances[0].Id)

lib/instances/resource_limits_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ func TestResourceLimits_ZeroMeansUnlimited(t *testing.T) {
203203
// cleanupTestProcesses kills any Cloud Hypervisor processes started during test
204204
func cleanupTestProcesses(t *testing.T, mgr *manager) {
205205
t.Helper()
206-
instances, err := mgr.ListInstances(context.Background())
206+
instances, err := mgr.ListInstances(context.Background(), nil)
207207
if err != nil {
208208
return
209209
}

0 commit comments

Comments
 (0)