Skip to content

Commit f3b8068

Browse files
feat(standalone): use typed labels.Selector for endpoint selector
Replace the string-based --endpoint-selector flag with Kubernetes-native labels.Selector to support set-based label expressions (e.g., 'env in (prod,staging),tier!=frontend', 'key,!deprecated') in standalone mode. - EndpointSelector type: string -> labels.Selector - Complete(): parse flag string via labels.Parse() - EndpointPool.Selector: map[string]string -> labels.Selector - PoolLabelsMatch simplified with native Selector.Matches() - podResyncAll passes Selector directly to K8s client - All callers and tests updated for new type Signed-off-by: shichaooutlook <shichao.outlook@gmail.com>
1 parent f7e8852 commit f3b8068

8 files changed

Lines changed: 79 additions & 44 deletions

File tree

cmd/epp/runner/runner.go

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ func (r *Runner) setup(ctx context.Context, cfg *rest.Config, opts *runserver.Op
287287
return nil, nil, err
288288
}
289289

290-
startCrdReconcilers := opts.EndpointSelector == "" // If endpointSelector is empty, it means it's not in the standalone mode. Then we should start the inferencePool and other CRD Reconciler.
290+
startCrdReconcilers := opts.EndpointSelector == nil // If endpointSelector is nil, it means it's not in the standalone mode. Then we should start the inferencePool and other CRD Reconciler.
291291
controllerCfg := runserver.NewControllerConfig(startCrdReconcilers)
292292
if err := controllerCfg.PopulateControllerConfig(cfg); err != nil {
293293
setupLog.Error(err, "Failed to populate controller config")
@@ -433,38 +433,31 @@ func (r *Runner) setup(ctx context.Context, cfg *rest.Config, opts *runserver.Op
433433
func NewEndpointPoolFromOptions(
434434
namespace string,
435435
name string,
436-
endpointSelector string,
436+
endpointSelector labels.Selector,
437437
endpointTargetPorts []int,
438438
) (*datalayer.EndpointPool, error) {
439-
// namespace is from epp namespace in standalone mode without inference api support
440439
if namespace == "" {
441440
return nil, errors.New("namespace must not be empty")
442441
}
443-
// name is from epp name in standalone mode without inference api support
444442
if name == "" {
445443
return nil, errors.New("name must not be empty")
446444
}
447-
if endpointSelector == "" {
448-
return nil, errors.New("endpoint selector must not be empty")
445+
if endpointSelector == nil {
446+
return nil, errors.New("endpoint selector must not be nil")
449447
}
450448
if len(endpointTargetPorts) == 0 {
451449
return nil, errors.New("endpoint target ports must not be empty")
452450
}
453451

454-
selectorMap, err := labels.ConvertSelectorToLabelsMap(endpointSelector)
455-
if err != nil {
456-
return nil, fmt.Errorf("failed to parse endpoint selector %q: %w", endpointSelector, err)
457-
}
458-
459452
pool := datalayer.NewEndpointPool(namespace, name)
460-
pool.Selector = selectorMap
453+
pool.Selector = endpointSelector
461454
pool.TargetPorts = append(pool.TargetPorts, endpointTargetPorts...)
462455

463456
return pool, nil
464457
}
465458

466459
func setupDatastore(ctx context.Context, epFactory datalayer.EndpointFactory, modelServerMetricsPort int32,
467-
startCrdReconcilers bool, namespace, name, endpointSelector string, endpointTargetPorts []int) (datastore.Datastore, error) {
460+
startCrdReconcilers bool, namespace, name string, endpointSelector labels.Selector, endpointTargetPorts []int) (datastore.Datastore, error) {
468461

469462
if startCrdReconcilers {
470463
return datastore.NewDatastore(ctx, epFactory, modelServerMetricsPort), nil
@@ -750,9 +743,8 @@ func extractDeploymentName(podName string) (string, error) {
750743
return "", fmt.Errorf("failed to parse deployment name from pod name %s", podName)
751744
}
752745

753-
func extractGKNN(poolName, poolGroup, poolNamespace, endpointSelector string) (*common.GKNN, error) {
746+
func extractGKNN(poolName, poolGroup, poolNamespace string, endpointSelector labels.Selector) (*common.GKNN, error) {
754747
if poolName != "" {
755-
// Determine pool namespace: if --pool-namespace is non-empty, use it; else NAMESPACE env var; else default
756748
resolvedPoolNamespace := resolvePoolNamespace(poolNamespace)
757749
poolNamespacedName := types.NamespacedName{
758750
Name: poolName,
@@ -768,10 +760,8 @@ func extractGKNN(poolName, poolGroup, poolNamespace, endpointSelector string) (*
768760
}, nil
769761
}
770762

771-
if endpointSelector != "" {
772-
// Determine EPP namespace: NAMESPACE env var; else default
763+
if endpointSelector != nil {
773764
resolvedPoolNamespace := resolvePoolNamespace(poolNamespace)
774-
// Determine EPP name: POD_NAME env var
775765
eppPodNameEnv := os.Getenv("POD_NAME")
776766
if eppPodNameEnv == "" {
777767
return nil, errors.New("failed to get environment variable POD_NAME")

pkg/epp/controller/inferencepool_reconciler_test.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"github.com/google/go-cmp/cmp"
2525
"github.com/google/go-cmp/cmp/cmpopts"
2626
corev1 "k8s.io/api/core/v1"
27+
"k8s.io/apimachinery/pkg/labels"
2728
"k8s.io/apimachinery/pkg/runtime"
2829
"k8s.io/apimachinery/pkg/runtime/schema"
2930
"k8s.io/apimachinery/pkg/types"
@@ -40,6 +41,18 @@ import (
4041
testutil "github.com/llm-d/llm-d-router/pkg/epp/util/testing"
4142
)
4243

44+
var endpointPoolCmpOpts = []cmp.Option{
45+
cmp.Comparer(func(a, b labels.Selector) bool {
46+
if a == nil && b == nil {
47+
return true
48+
}
49+
if a == nil || b == nil {
50+
return false
51+
}
52+
return a.String() == b.String()
53+
}),
54+
}
55+
4356
var (
4457
selectorV1 = map[string]string{"app": "vllm_v1"}
4558
selectorV2 = map[string]string{"app": "vllm_v2"}
@@ -181,7 +194,7 @@ type diffStoreParams struct {
181194

182195
func diffStore(store datastore.Datastore, params diffStoreParams) string {
183196
gotPool, _ := store.PoolGet()
184-
if diff := cmp.Diff(params.wantPool, gotPool); diff != "" {
197+
if diff := cmp.Diff(params.wantPool, gotPool, endpointPoolCmpOpts...); diff != "" {
185198
return "inferencePool:" + diff
186199
}
187200

pkg/epp/datalayer/endpoint_pool.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,12 @@ limitations under the License.
1717
package datalayer
1818

1919
import (
20+
"k8s.io/apimachinery/pkg/labels"
2021
v1 "sigs.k8s.io/gateway-api-inference-extension/api/v1"
2122
)
2223

2324
type EndpointPool struct {
24-
Selector map[string]string
25+
Selector labels.Selector
2526
TargetPorts []int
2627
Namespace string
2728
Name string
@@ -31,7 +32,7 @@ type EndpointPool struct {
3132
// NewEndpointPool creates and returns a new empty instance of EndpointPool.
3233
func NewEndpointPool(namespace string, name string) *EndpointPool {
3334
return &EndpointPool{
34-
Selector: make(map[string]string),
35+
Selector: labels.Everything(),
3536
TargetPorts: []int{},
3637
Namespace: namespace,
3738
Name: name,

pkg/epp/datastore/datastore.go

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ func (ds *datastore) PoolSet(ctx context.Context, reader client.Reader, endpoint
168168
oldEndpointPool := ds.pool
169169
ds.pool = endpointPool
170170

171-
selectorChanged := oldEndpointPool == nil || !labels.Equals(oldEndpointPool.Selector, endpointPool.Selector)
171+
selectorChanged := oldEndpointPool == nil || !selectorEqual(oldEndpointPool.Selector, endpointPool.Selector)
172172
targetPortsChanged := oldEndpointPool != nil && !slices.Equal(oldEndpointPool.TargetPorts, endpointPool.TargetPorts)
173173

174174
if selectorChanged || targetPortsChanged {
@@ -207,12 +207,10 @@ func (ds *datastore) PoolHasSynced() bool {
207207
func (ds *datastore) PoolLabelsMatch(podLabels map[string]string) bool {
208208
ds.mu.RLock()
209209
defer ds.mu.RUnlock()
210-
if ds.pool == nil {
210+
if ds.pool == nil || ds.pool.Selector == nil {
211211
return false
212212
}
213-
poolSelector := labels.SelectorFromSet(ds.pool.Selector)
214-
podSet := labels.Set(podLabels)
215-
return poolSelector.Matches(podSet)
213+
return ds.pool.Selector.Matches(labels.Set(podLabels))
216214
}
217215

218216
// /// InferenceObjective APIs ///
@@ -415,7 +413,7 @@ func (ds *datastore) podResyncAll(ctx context.Context, reader client.Reader) err
415413
logger := log.FromContext(ctx)
416414
podList := &corev1.PodList{}
417415
if err := reader.List(ctx, podList, &client.ListOptions{
418-
LabelSelector: labels.SelectorFromSet(ds.pool.Selector),
416+
LabelSelector: ds.pool.Selector,
419417
Namespace: ds.pool.Namespace,
420418
}); err != nil {
421419
return fmt.Errorf("failed to list pods - %w", err)
@@ -487,3 +485,13 @@ func createEndpointNamespacedName(pod *corev1.Pod, idx int) types.NamespacedName
487485
Namespace: pod.Namespace,
488486
}
489487
}
488+
489+
func selectorEqual(a, b labels.Selector) bool {
490+
if a == nil && b == nil {
491+
return true
492+
}
493+
if a == nil || b == nil {
494+
return false
495+
}
496+
return a.String() == b.String()
497+
}

pkg/epp/datastore/datastore_test.go

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import (
3131
"github.com/stretchr/testify/assert"
3232
corev1 "k8s.io/api/core/v1"
3333
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
34+
"k8s.io/apimachinery/pkg/labels"
3435
"k8s.io/apimachinery/pkg/runtime"
3536
"k8s.io/apimachinery/pkg/types"
3637
"k8s.io/apimachinery/pkg/util/sets"
@@ -46,6 +47,18 @@ import (
4647
testutil "github.com/llm-d/llm-d-router/pkg/epp/util/testing"
4748
)
4849

50+
var endpointPoolCmpOpts = []cmp.Option{
51+
cmp.Comparer(func(a, b labels.Selector) bool {
52+
if a == nil && b == nil {
53+
return true
54+
}
55+
if a == nil || b == nil {
56+
return false
57+
}
58+
return a.String() == b.String()
59+
}),
60+
}
61+
4962
// mockEndpointFactory is a minimal EndpointFactory for EndpointUpsert/Delete tests.
5063
// When returnNil is true, NewEndpoint returns nil (simulating a duplicate-start race).
5164
type mockEndpointFactory struct {
@@ -78,7 +91,7 @@ func (f *mockEndpointFactory) updateEvents() []fwkdl.Endpoint {
7891
func TestPoolGet_NoDeadlockWithConcurrentWrite(t *testing.T) {
7992
pool := &datalayer.EndpointPool{
8093
Namespace: "default",
81-
Selector: map[string]string{"app": "vllm"},
94+
Selector: labels.SelectorFromSet(labels.Set{"app": "vllm"}),
8295
TargetPorts: []int{8000},
8396
}
8497
ds := &datastore{pool: pool}
@@ -161,7 +174,7 @@ func TestPool(t *testing.T) {
161174
if diff := cmp.Diff(tt.wantErr, gotErr, cmpopts.EquateErrors()); diff != "" {
162175
t.Errorf("Unexpected error diff (+got/-want): %s", diff)
163176
}
164-
if diff := cmp.Diff(poolutil.InferencePoolToEndpointPool(tt.wantPool), gotPool); diff != "" {
177+
if diff := cmp.Diff(poolutil.InferencePoolToEndpointPool(tt.wantPool), gotPool, endpointPoolCmpOpts...); diff != "" {
165178
t.Errorf("Unexpected pool diff (+got/-want): %s", diff)
166179
}
167180
gotSynced := ds.PoolHasSynced()

pkg/epp/server/options.go

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525

2626
"github.com/spf13/pflag"
2727
"k8s.io/apimachinery/pkg/api/resource"
28+
"k8s.io/apimachinery/pkg/labels"
2829
"k8s.io/apimachinery/pkg/util/sets"
2930

3031
"github.com/llm-d/llm-d-router/pkg/common/observability/logging"
@@ -72,9 +73,9 @@ type Options struct {
7273
//
7374
// Endpoints (in lieu of using an InferencePool for service discovery).
7475
//
75-
EndpointSelector string // Selector to filter model server pods on, only 'key=value' pairs are supported. (TODO: k8s.Selector, pflag.StringSlice?)
76-
EndpointTargetPorts []int // Target ports of model server pods.
77-
DisableEndpointSubsetFilter bool // Disables respecting destination endpoint subset metadata in EPP.
76+
EndpointSelector labels.Selector // Parsed selector to filter model server pods on. Set via --endpoint-selector flag and parsed in Complete().
77+
EndpointTargetPorts []int // Target ports of model server pods.
78+
DisableEndpointSubsetFilter bool // Disables respecting destination endpoint subset metadata in EPP.
7879
//
7980
// MSP metrics scraping.
8081
//
@@ -111,7 +112,8 @@ type Options struct {
111112
ConfigText string // The configuration specified as text, in lieu of a file.
112113

113114
// internal
114-
fs *pflag.FlagSet // FlagSet used in AddFlags() and consulted in Validate()
115+
fs *pflag.FlagSet // FlagSet used in AddFlags() and consulted in Validate()
116+
endpointSelectorStr string // Raw string from --endpoint-selector flag, parsed to EndpointSelector in Complete()
115117
}
116118

117119
// NewOptions returns a new Options struct initialized with the default values.
@@ -158,9 +160,10 @@ func (opts *Options) AddFlags(fs *pflag.FlagSet) {
158160
fs.StringVar(&opts.PoolNamespace, "pool-namespace", opts.PoolNamespace,
159161
"Namespace of the InferencePool this Endpoint Picker is associated with.")
160162
fs.StringVar(&opts.PoolName, "pool-name", opts.PoolName, "Name of the InferencePool this Endpoint Picker is associated with.")
161-
fs.StringVar(&opts.EndpointSelector, "endpoint-selector", opts.EndpointSelector,
162-
"Selector to filter model server pods on, only 'key=value' pairs are supported. "+
163-
"Format: a comma-separated list of key=value pairs without whitespace (e.g., 'app=vllm-qwen3-32b,env=prod').")
163+
fs.StringVar(&opts.endpointSelectorStr, "endpoint-selector", opts.endpointSelectorStr,
164+
"Selector to filter model server pods on. "+
165+
"Supports Kubernetes label selector syntax: equality-based (e.g., 'app=vllm,env=prod'), "+
166+
"set-based (e.g., 'env in (prod,staging),tier!=frontend'), and existence (e.g., 'key,!deprecated').")
164167
fs.IntSliceVar(&opts.EndpointTargetPorts, "endpoint-target-ports", opts.EndpointTargetPorts, "Target ports of model server pods. "+
165168
"Format: a comma-separated list of numbers without whitespace (e.g., '3000,3001,3002').")
166169
fs.BoolVar(&opts.DisableEndpointSubsetFilter, "disable-endpoint-subset-filter", opts.DisableEndpointSubsetFilter,
@@ -222,8 +225,13 @@ func (opts *Options) AddFlags(fs *pflag.FlagSet) {
222225
}
223226

224227
func (opts *Options) Complete() error {
225-
// TODO: postprocessing or command line arguments. For example, convert EndpointSelector
226-
// from raw string to k8s.LabelSelector, load ConfigFile into ConfigText, etc.
228+
if opts.endpointSelectorStr != "" {
229+
selector, err := labels.Parse(opts.endpointSelectorStr)
230+
if err != nil {
231+
return fmt.Errorf("invalid endpoint-selector %q: %w", opts.endpointSelectorStr, err)
232+
}
233+
opts.EndpointSelector = selector
234+
}
227235

228236
opts.EndpointTargetPorts = removeDuplicatePorts(opts.EndpointTargetPorts)
229237

@@ -269,10 +277,10 @@ func (opts *Options) Complete() error {
269277
}
270278

271279
func (opts *Options) Validate() error {
272-
if (opts.PoolName != "" && opts.EndpointSelector != "") || (opts.PoolName == "" && opts.EndpointSelector == "") {
280+
if (opts.PoolName != "" && opts.EndpointSelector != nil) || (opts.PoolName == "" && opts.EndpointSelector == nil) {
273281
return errors.New("either pool-name or endpoint-selector must be set")
274282
}
275-
if opts.EndpointSelector != "" {
283+
if opts.EndpointSelector != nil {
276284
if len(opts.EndpointTargetPorts) == 0 || len(opts.EndpointTargetPorts) > 8 {
277285
return fmt.Errorf("flag %q should have length from 1 to 8", "endpoint-target-ports")
278286
}

pkg/epp/util/pool/pool.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ limitations under the License.
1717
package pool
1818

1919
import (
20+
"k8s.io/apimachinery/pkg/labels"
2021
v1 "sigs.k8s.io/gateway-api-inference-extension/api/v1"
2122

2223
"github.com/llm-d/llm-d-router/pkg/epp/datalayer"
@@ -31,12 +32,12 @@ func InferencePoolToEndpointPool(inferencePool *v1.InferencePool) *datalayer.End
3132
targetPorts = append(targetPorts, int(p.Number))
3233

3334
}
34-
selector := make(map[string]string, len(inferencePool.Spec.Selector.MatchLabels))
35+
selectorMap := make(map[string]string, len(inferencePool.Spec.Selector.MatchLabels))
3536
for k, v := range inferencePool.Spec.Selector.MatchLabels {
36-
selector[string(k)] = string(v)
37+
selectorMap[string(k)] = string(v)
3738
}
3839
endpointPool := &datalayer.EndpointPool{
39-
Selector: selector,
40+
Selector: labels.SelectorFromSet(labels.Set(selectorMap)),
4041
TargetPorts: targetPorts,
4142
Namespace: inferencePool.Namespace,
4243
Name: inferencePool.Name,

test/integration/epp/harness.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import (
3737
corev1 "k8s.io/api/core/v1"
3838
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
3939
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
40+
"k8s.io/apimachinery/pkg/labels"
4041
"k8s.io/apimachinery/pkg/runtime"
4142
"k8s.io/apimachinery/pkg/types"
4243
metricsutils "k8s.io/component-base/metrics/testutil"
@@ -204,7 +205,7 @@ func NewTestHarness(ctx context.Context, t *testing.T, opts ...HarnessOption) *T
204205
eppOptions := defaultEppServerOptions(t, testNamespaceName, configText)
205206
if config.runMode == modeStandalone && config.standaloneStrategy == strategyNoCRD {
206207
// Only standalone EPP without crd need to set the EndpointSelector.
207-
eppOptions.EndpointSelector = "app=" + testPoolName
208+
eppOptions.EndpointSelector = labels.SelectorFromSet(labels.Set{"app": testPoolName})
208209
}
209210

210211
// Shorten the Prometheus refresh interval so WaitForReadyPodsMetric (10s timeout)

0 commit comments

Comments
 (0)