Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
17 changes: 15 additions & 2 deletions cmd/api/api/instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,24 @@ import (
"github.com/samber/lo"
)

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

domainInsts, err := s.InstanceManager.ListInstances(ctx)
// Convert OAPI params to domain filter
var filter *instances.ListInstancesFilter
if request.Params.State != nil || request.Params.Metadata != nil {
filter = &instances.ListInstancesFilter{}
if request.Params.State != nil {
state := instances.State(*request.Params.State)
filter.State = &state
}
if request.Params.Metadata != nil {
filter.Metadata = *request.Params.Metadata
}
}

domainInsts, err := s.InstanceManager.ListInstances(ctx, filter)
if err != nil {
log.ErrorContext(ctx, "failed to list instances", "error", err)
return oapi.ListInstances500JSONResponse{
Expand Down
2 changes: 1 addition & 1 deletion cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ func run() error {
// Include Unknown state: we couldn't confirm their state, but they might still
// have a running VMM. Better to leave a stale TAP than crash a running VM.
var preserveTAPs []string
allInstances, err := app.InstanceManager.ListInstances(app.Ctx)
allInstances, err := app.InstanceManager.ListInstances(app.Ctx, nil)
if err != nil {
// On error, skip TAP cleanup entirely to avoid crashing running VMs.
// Pass nil to Initialize to skip cleanup.
Expand Down
6 changes: 4 additions & 2 deletions lib/builds/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,12 @@ func newMockInstanceManager() *mockInstanceManager {
}
}

func (m *mockInstanceManager) ListInstances(ctx context.Context) ([]instances.Instance, error) {
func (m *mockInstanceManager) ListInstances(ctx context.Context, filter *instances.ListInstancesFilter) ([]instances.Instance, error) {
var result []instances.Instance
for _, inst := range m.instances {
result = append(result, *inst)
if filter == nil || filter.Matches(inst) {
result = append(result, *inst)
}
}
return result, nil
}
Expand Down
261 changes: 261 additions & 0 deletions lib/instances/filter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
package instances

import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"time"

"github.com/kernel/hypeman/lib/hypervisor"
"github.com/kernel/hypeman/lib/paths"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestListInstancesFilter_Matches(t *testing.T) {
running := StateRunning
stopped := StateStopped

inst := &Instance{
StoredMetadata: StoredMetadata{
Id: "inst-1",
Name: "web-server",
Image: "nginx:latest",
Metadata: map[string]string{
"team": "backend",
"env": "staging",
},
},
State: StateRunning,
}

tests := []struct {
name string
filter *ListInstancesFilter
want bool
}{
{
name: "nil filter matches everything",
filter: nil,
want: true,
},
{
name: "empty filter matches everything",
filter: &ListInstancesFilter{},
want: true,
},
{
name: "state filter matches",
filter: &ListInstancesFilter{State: &running},
want: true,
},
{
name: "state filter does not match",
filter: &ListInstancesFilter{State: &stopped},
want: false,
},
{
name: "single metadata key matches",
filter: &ListInstancesFilter{
Metadata: map[string]string{"team": "backend"},
},
want: true,
},
{
name: "single metadata key wrong value",
filter: &ListInstancesFilter{
Metadata: map[string]string{"team": "frontend"},
},
want: false,
},
{
name: "metadata key does not exist",
filter: &ListInstancesFilter{
Metadata: map[string]string{"project": "alpha"},
},
want: false,
},
{
name: "multiple metadata keys all match",
filter: &ListInstancesFilter{
Metadata: map[string]string{
"team": "backend",
"env": "staging",
},
},
want: true,
},
{
name: "multiple metadata keys partial match",
filter: &ListInstancesFilter{
Metadata: map[string]string{
"team": "backend",
"env": "production",
},
},
want: false,
},
{
name: "state and metadata combined match",
filter: &ListInstancesFilter{
State: &running,
Metadata: map[string]string{"team": "backend"},
},
want: true,
},
{
name: "state matches but metadata does not",
filter: &ListInstancesFilter{
State: &running,
Metadata: map[string]string{"team": "frontend"},
},
want: false,
},
{
name: "metadata matches but state does not",
filter: &ListInstancesFilter{
State: &stopped,
Metadata: map[string]string{"team": "backend"},
},
want: false,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := tc.filter.Matches(inst)
assert.Equal(t, tc.want, got)
})
}
}

func TestListInstancesFilter_Matches_NilMetadata(t *testing.T) {
inst := &Instance{
StoredMetadata: StoredMetadata{
Id: "inst-2",
Metadata: nil,
},
State: StateRunning,
}

filter := &ListInstancesFilter{
Metadata: map[string]string{"team": "backend"},
}
assert.False(t, filter.Matches(inst), "should not match when instance has no metadata")
}

// TestListInstances_WithFilter exercises the full ListInstances path using
// on-disk metadata files (no KVM required).
func TestListInstances_WithFilter(t *testing.T) {
tmpDir := t.TempDir()
p := paths.New(tmpDir)

mgr := &manager{paths: p}

// Create three instances with different metadata on disk
instances := []StoredMetadata{
{
Id: "inst-a",
Name: "web",
Image: "nginx:latest",
Metadata: map[string]string{"team": "backend", "env": "prod"},
CreatedAt: time.Now(),
HypervisorType: hypervisor.TypeCloudHypervisor,
SocketPath: "/nonexistent/a.sock",
DataDir: p.InstanceDir("inst-a"),
},
{
Id: "inst-b",
Name: "worker",
Image: "python:3",
Metadata: map[string]string{"team": "backend", "env": "staging"},
CreatedAt: time.Now(),
HypervisorType: hypervisor.TypeCloudHypervisor,
SocketPath: "/nonexistent/b.sock",
DataDir: p.InstanceDir("inst-b"),
},
{
Id: "inst-c",
Name: "frontend",
Image: "node:20",
Metadata: map[string]string{"team": "frontend", "env": "prod"},
CreatedAt: time.Now(),
HypervisorType: hypervisor.TypeCloudHypervisor,
SocketPath: "/nonexistent/c.sock",
DataDir: p.InstanceDir("inst-c"),
},
}

for _, stored := range instances {
require.NoError(t, mgr.ensureDirectories(stored.Id))
data, err := json.MarshalIndent(&metadata{StoredMetadata: stored}, "", " ")
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(p.InstanceDir(stored.Id), "metadata.json"), data, 0644))
}

ctx := context.Background()

t.Run("no filter returns all", func(t *testing.T) {
result, err := mgr.ListInstances(ctx, nil)
require.NoError(t, err)
assert.Len(t, result, 3)
})

t.Run("filter by single metadata key", func(t *testing.T) {
result, err := mgr.ListInstances(ctx, &ListInstancesFilter{
Metadata: map[string]string{"team": "backend"},
})
require.NoError(t, err)
assert.Len(t, result, 2)
names := []string{result[0].Name, result[1].Name}
assert.ElementsMatch(t, []string{"web", "worker"}, names)
})

t.Run("filter by two metadata keys", func(t *testing.T) {
result, err := mgr.ListInstances(ctx, &ListInstancesFilter{
Metadata: map[string]string{"team": "backend", "env": "prod"},
})
require.NoError(t, err)
require.Len(t, result, 1)
assert.Equal(t, "web", result[0].Name)
})

t.Run("filter by metadata with no matches", func(t *testing.T) {
result, err := mgr.ListInstances(ctx, &ListInstancesFilter{
Metadata: map[string]string{"team": "devops"},
})
require.NoError(t, err)
assert.Empty(t, result)
})

t.Run("filter by state", func(t *testing.T) {
// All instances have no socket so they're Stopped
stopped := StateStopped
result, err := mgr.ListInstances(ctx, &ListInstancesFilter{
State: &stopped,
})
require.NoError(t, err)
assert.Len(t, result, 3)

running := StateRunning
result, err = mgr.ListInstances(ctx, &ListInstancesFilter{
State: &running,
})
require.NoError(t, err)
assert.Empty(t, result)
})

t.Run("filter by state and metadata combined", func(t *testing.T) {
stopped := StateStopped
result, err := mgr.ListInstances(ctx, &ListInstancesFilter{
State: &stopped,
Metadata: map[string]string{"env": "prod"},
})
require.NoError(t, err)
assert.Len(t, result, 2)
names := []string{result[0].Name, result[1].Name}
assert.ElementsMatch(t, []string{"web", "frontend"}, names)
})
}
24 changes: 19 additions & 5 deletions lib/instances/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import (
)

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

// ListInstances returns all instances
func (m *manager) ListInstances(ctx context.Context) ([]Instance, error) {
// ListInstances returns instances, optionally filtered by the given criteria.
// Pass nil to return all instances.
func (m *manager) ListInstances(ctx context.Context, filter *ListInstancesFilter) ([]Instance, error) {
// No lock - eventual consistency is acceptable for list operations.
// State is derived dynamically, so list is always reasonably current.
return m.listInstances(ctx)
all, err := m.listInstances(ctx)
if err != nil {
return nil, err
}
if filter == nil {
return all, nil
}
filtered := make([]Instance, 0, len(all))
for i := range all {
if filter.Matches(&all[i]) {
filtered = append(filtered, all[i])
}
}
return filtered, nil
}

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

// 2. List all instances for name and prefix matching
instances, err := m.ListInstances(ctx)
instances, err := m.ListInstances(ctx, nil)
if err != nil {
return nil, err
}
Expand Down
2 changes: 1 addition & 1 deletion lib/instances/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ func TestBasicEndToEnd(t *testing.T) {
assert.Equal(t, StateRunning, retrieved.State)

// List instances
instances, err := manager.ListInstances(ctx)
instances, err := manager.ListInstances(ctx, nil)
require.NoError(t, err)
assert.Len(t, instances, 1)
assert.Equal(t, inst.Id, instances[0].Id)
Expand Down
2 changes: 1 addition & 1 deletion lib/instances/qemu_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ func TestQEMUBasicEndToEnd(t *testing.T) {
assert.Equal(t, StateRunning, retrieved.State)

// List instances
instances, err := manager.ListInstances(ctx)
instances, err := manager.ListInstances(ctx, nil)
require.NoError(t, err)
assert.Len(t, instances, 1)
assert.Equal(t, inst.Id, instances[0].Id)
Expand Down
2 changes: 1 addition & 1 deletion lib/instances/resource_limits_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ func TestResourceLimits_ZeroMeansUnlimited(t *testing.T) {
// cleanupTestProcesses kills any Cloud Hypervisor processes started during test
func cleanupTestProcesses(t *testing.T, mgr *manager) {
t.Helper()
instances, err := mgr.ListInstances(context.Background())
instances, err := mgr.ListInstances(context.Background(), nil)
if err != nil {
return
}
Expand Down
Loading