Implement server-side filtering for VMs by worker (#392)

* Implement server-side filtering for VMs by worker

* Parse more than one filter but error out when more than one is provided

* Fix off-by-one

* No need to use "\n" in Debugf()
This commit is contained in:
Nikolay Edigaryev 2026-01-29 17:52:24 +01:00 committed by GitHub
parent 81a2c7b2df
commit 688238837a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 215 additions and 11 deletions

View File

@ -243,6 +243,13 @@ paths:
summary: "List VMs" summary: "List VMs"
tags: tags:
- vms - vms
parameters:
- in: query
name: filter
description: "Filter VMs using `path=value` syntax; currently only `worker=<name>` is supported to return VMs assigned to the given worker"
schema:
type: string
required: false
responses: responses:
'200': '200':
description: OK description: OK

View File

@ -2,14 +2,15 @@ package controller
import ( import (
"errors" "errors"
"net/http"
"time"
storepkg "github.com/cirruslabs/orchard/internal/controller/store" storepkg "github.com/cirruslabs/orchard/internal/controller/store"
"github.com/cirruslabs/orchard/internal/responder" "github.com/cirruslabs/orchard/internal/responder"
"github.com/cirruslabs/orchard/internal/simplename" "github.com/cirruslabs/orchard/internal/simplename"
v1 "github.com/cirruslabs/orchard/pkg/resource/v1" v1 "github.com/cirruslabs/orchard/pkg/resource/v1"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"net/http"
"time"
) )
func (controller *Controller) createServiceAccount(ctx *gin.Context) responder.Responder { func (controller *Controller) createServiceAccount(ctx *gin.Context) responder.Responder {

View File

@ -6,6 +6,7 @@ import (
"errors" "errors"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/cirruslabs/orchard/internal/controller/lifecycle" "github.com/cirruslabs/orchard/internal/controller/lifecycle"
@ -298,8 +299,30 @@ func (controller *Controller) listVMs(ctx *gin.Context) responder.Responder {
return responder return responder
} }
var opts []storepkg.ListOption
if filterRaw := ctx.Query("filter"); filterRaw != "" {
var filters []v1.Filter
for _, filterRaw := range strings.Split(filterRaw, ",") {
filter, err := v1.NewFilter(filterRaw)
if err != nil {
return responder.JSON(http.StatusPreconditionFailed, NewErrorResponse("%v", err))
}
filters = append(filters, filter)
}
if len(filters) > 1 {
return responder.JSON(http.StatusPreconditionFailed, NewErrorResponse("only "+
"a single filter is currently supported"))
}
opts = append(opts, storepkg.WithListFilters(filters...))
}
return controller.storeView(func(txn storepkg.Transaction) responder.Responder { return controller.storeView(func(txn storepkg.Transaction) responder.Responder {
vms, err := txn.ListVMs() vms, err := txn.ListVMs(opts...)
if err != nil { if err != nil {
return responder.Error(err) return responder.Error(err)
} }

View File

@ -3,6 +3,8 @@ package badger
import ( import (
"encoding/json" "encoding/json"
storepkg "github.com/cirruslabs/orchard/internal/controller/store"
v1 "github.com/cirruslabs/orchard/pkg/resource/v1"
"github.com/dgraph-io/badger/v3" "github.com/dgraph-io/badger/v3"
) )
@ -51,12 +53,20 @@ func genericGet[T any, PT interface {
func genericList[T any, PT interface { func genericList[T any, PT interface {
SetVersion(uint64) SetVersion(uint64)
Match(v1.Filter) bool
*T *T
}](txn *Transaction, prefix []byte) (_ []T, err error) { }](txn *Transaction, prefix []byte, opts ...storepkg.ListOption) (_ []T, err error) {
defer func() { defer func() {
err = mapErr(err) err = mapErr(err)
}() }()
// Apply options
listInput := &storepkg.ListInput{}
for _, opt := range opts {
opt(listInput)
}
// Declare an empty, non-nil slice to // Declare an empty, non-nil slice to
// return [] when no objects are found // return [] when no objects are found
result := []T{} result := []T{}
@ -66,6 +76,7 @@ func genericList[T any, PT interface {
}) })
defer it.Close() defer it.Close()
Outer:
for it.Rewind(); it.Valid(); it.Next() { for it.Rewind(); it.Valid(); it.Next() {
item := it.Item() item := it.Item()
@ -80,6 +91,12 @@ func genericList[T any, PT interface {
return nil, err return nil, err
} }
for _, filter := range listInput.Filters {
if !PT(&obj).Match(filter) {
continue Outer
}
}
PT(&obj).SetVersion(item.Version()) PT(&obj).SetVersion(item.Version())
result = append(result, obj) result = append(result, obj)

View File

@ -4,6 +4,7 @@ package badger
import ( import (
"path" "path"
storepkg "github.com/cirruslabs/orchard/internal/controller/store"
"github.com/cirruslabs/orchard/pkg/resource/v1" "github.com/cirruslabs/orchard/pkg/resource/v1"
) )
@ -25,6 +26,6 @@ func (txn *Transaction) DeleteVM(name string) error {
return genericDelete(txn, VMKey(name)) return genericDelete(txn, VMKey(name))
} }
func (txn *Transaction) ListVMs() ([]v1.VM, error) { func (txn *Transaction) ListVMs(opts ...storepkg.ListOption) ([]v1.VM, error) {
return genericList[v1.VM](txn, []byte(SpaceVMs)) return genericList[v1.VM](txn, []byte(SpaceVMs), opts...)
} }

View File

@ -0,0 +1,15 @@
package store
import v1 "github.com/cirruslabs/orchard/pkg/resource/v1"
type ListInput struct {
Filters []v1.Filter
}
type ListOption func(listInput *ListInput)
func WithListFilters(filters ...v1.Filter) ListOption {
return func(listInput *ListInput) {
listInput.Filters = filters
}
}

View File

@ -29,7 +29,7 @@ type Transaction interface {
GetVM(name string) (result *v1.VM, err error) GetVM(name string) (result *v1.VM, err error)
SetVM(vm v1.VM) (err error) SetVM(vm v1.VM) (err error)
DeleteVM(name string) (err error) DeleteVM(name string) (err error)
ListVMs() (result []v1.VM, err error) ListVMs(opts ...ListOption) (result []v1.VM, err error)
GetWorker(name string) (result *v1.Worker, err error) GetWorker(name string) (result *v1.Worker, err error)
SetWorker(worker v1.Worker) (err error) SetWorker(worker v1.Worker) (err error)

View File

@ -339,7 +339,7 @@ func (worker *Worker) syncVMs(ctx context.Context, updateVM func(context.Context
action := transitions[remoteState][localState] action := transitions[remoteState][localState]
worker.logger.Debugf("processing VM: %s, remote state: %s, local state: %s, "+ worker.logger.Debugf("processing VM: %s, remote state: %s, local state: %s, "+
"local conditions: [%s], action: %v\n", onDiskName, optionToString(remoteState), "local conditions: [%s], action: %v", onDiskName, optionToString(remoteState),
optionToString(localState), v1.ConditionsHumanize(localConditions), action) optionToString(localState), v1.ConditionsHumanize(localConditions), action)
switch action { switch action {

View File

@ -2,8 +2,11 @@ package client
import ( import (
"crypto/x509" "crypto/x509"
"fmt"
"strings"
"github.com/cirruslabs/orchard/internal/dialer" "github.com/cirruslabs/orchard/internal/dialer"
v1 "github.com/cirruslabs/orchard/pkg/resource/v1"
) )
type Option func(*Client) type Option func(*Client)
@ -32,3 +35,25 @@ func WithDialer(dialer dialer.Dialer) Option {
client.dialer = dialer client.dialer = dialer
} }
} }
type ListInput struct {
Filters []v1.Filter
}
type ListOption func(params map[string]string)
func WithListFilters(filters ...v1.Filter) ListOption {
return func(params map[string]string) {
if len(filters) == 0 {
return
}
var pairs []string
for _, filter := range filters {
pairs = append(pairs, fmt.Sprintf("%s=%s", filter.Path, filter.Value))
}
params["filter"] = strings.Join(pairs, ",")
}
}

View File

@ -57,13 +57,18 @@ func (service *VMsService) Create(ctx context.Context, vm *v1.VM) error {
} }
func (service *VMsService) FindForWorker(ctx context.Context, worker string) ([]v1.VM, error) { func (service *VMsService) FindForWorker(ctx context.Context, worker string) ([]v1.VM, error) {
allVms, err := service.List(ctx) allVms, err := service.List(ctx, WithListFilters(v1.Filter{
Path: "worker",
Value: worker,
}))
if err != nil { if err != nil {
return nil, err return nil, err
} }
var result []v1.VM var result []v1.VM
// Backwards compatibility with older Orchard Controllers
// that do not support the "filter" query parameter
for _, vmResource := range allVms { for _, vmResource := range allVms {
if vmResource.Worker != worker { if vmResource.Worker != worker {
continue continue
@ -75,11 +80,18 @@ func (service *VMsService) FindForWorker(ctx context.Context, worker string) ([]
return result, nil return result, nil
} }
func (service *VMsService) List(ctx context.Context) ([]v1.VM, error) { func (service *VMsService) List(ctx context.Context, opts ...ListOption) ([]v1.VM, error) {
params := map[string]string{}
// Apply options
for _, opt := range opts {
opt(params)
}
var vms []v1.VM var vms []v1.VM
err := service.client.request(ctx, http.MethodGet, "vms", err := service.client.request(ctx, http.MethodGet, "vms",
nil, &vms, nil) nil, &vms, params)
if err != nil { if err != nil {
return nil, err return nil, err
} }

31
pkg/resource/v1/filter.go Normal file
View File

@ -0,0 +1,31 @@
package v1
import (
"errors"
"fmt"
"strings"
)
var ErrInvalidFilter = errors.New("invalid filter")
type Filter struct {
Path string
Value string
}
func NewFilter(s string) (Filter, error) {
parts := strings.SplitN(s, "=", 2)
if len(parts) != 2 {
return Filter{}, fmt.Errorf("%w: expected path=value", ErrInvalidFilter)
}
if parts[0] == "" {
return Filter{}, fmt.Errorf("%w: path cannot be empty", ErrInvalidFilter)
}
return Filter{
Path: parts[0],
Value: parts[1],
}, nil
}

View File

@ -0,0 +1,55 @@
package v1_test
import (
"testing"
v1 "github.com/cirruslabs/orchard/pkg/resource/v1"
"github.com/stretchr/testify/require"
)
func TestNewFilter(t *testing.T) {
testCases := []struct {
Name string
Input string
Err error
Path string
Value string
}{
{
Name: "simple",
Input: "a.b.c=value",
Path: "a.b.c",
Value: "value",
},
{
Name: "value with equals",
Input: "a.b.c=d=e",
Path: "a.b.c",
Value: "d=e",
},
{
Name: "empty value",
Input: "a.b.c=",
Path: "a.b.c",
},
{
Name: "missing value",
Input: "abc",
Err: v1.ErrInvalidFilter,
},
{
Name: "missing path",
Input: "=value",
Err: v1.ErrInvalidFilter,
},
}
for _, testCase := range testCases {
t.Run(testCase.Name, func(t *testing.T) {
filter, err := v1.NewFilter(testCase.Input)
require.ErrorIs(t, err, testCase.Err)
require.Equal(t, testCase.Path, filter.Path)
require.Equal(t, testCase.Value, filter.Value)
})
}
}

View File

@ -8,3 +8,7 @@ type ServiceAccount struct {
} }
func (serviceAccount *ServiceAccount) SetVersion(_ uint64) {} func (serviceAccount *ServiceAccount) SetVersion(_ uint64) {}
func (serviceAccount *ServiceAccount) Match(filter Filter) bool {
return false
}

View File

@ -102,6 +102,15 @@ func (vm *VM) SetVersion(version uint64) {
vm.Version = version vm.Version = version
} }
func (vm *VM) Match(filter Filter) bool {
switch filter.Path {
case "worker":
return vm.Worker == filter.Value
default:
return false
}
}
func (vm *VM) IsScheduled() bool { func (vm *VM) IsScheduled() bool {
if ConditionExists(vm.Conditions, ConditionTypeScheduled) { if ConditionExists(vm.Conditions, ConditionTypeScheduled) {
return ConditionIsTrue(vm.Conditions, ConditionTypeScheduled) return ConditionIsTrue(vm.Conditions, ConditionTypeScheduled)

View File

@ -32,3 +32,7 @@ func (worker Worker) Offline(workerOfflineTimeout time.Duration) bool {
} }
func (worker *Worker) SetVersion(_ uint64) {} func (worker *Worker) SetVersion(_ uint64) {}
func (worker *Worker) Match(filter Filter) bool {
return false
}