Skip to content

Commit 6c5bf6a

Browse files
committed
draft cel test
1 parent d9cbca5 commit 6c5bf6a

File tree

2 files changed

+259
-0
lines changed

2 files changed

+259
-0
lines changed

test/cel/inferencepool_test.go

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package main
18+
19+
import (
20+
"context"
21+
"fmt"
22+
"testing"
23+
"time"
24+
25+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
26+
"sigs.k8s.io/gateway-api-inference-extension/api/v1"
27+
)
28+
29+
func TestValidateInferencePool(t *testing.T) {
30+
ctx := context.Background()
31+
32+
// baseInferencePool is a valid, minimal InferencePool resource.
33+
// We use a non-Service kind for the picker to ensure the base object is valid
34+
// without needing a port, making it a neutral starting point for mutations.
35+
baseInferencePool := v1.InferencePool{
36+
ObjectMeta: metav1.ObjectMeta{
37+
Name: "base-pool",
38+
Namespace: metav1.NamespaceDefault,
39+
},
40+
Spec: v1.InferencePoolSpec{
41+
TargetPorts: []v1.Port{
42+
{Number: 8000},
43+
},
44+
Selector: v1.LabelSelector{
45+
MatchLabels: map[v1.LabelKey]v1.LabelValue{
46+
"app": "my-model-server",
47+
},
48+
},
49+
EndpointPickerRef: v1.EndpointPickerRef{
50+
Name: "epp",
51+
Kind: "Service",
52+
Port: ptrTo(v1.Port{Number: 9000}),
53+
},
54+
},
55+
}
56+
57+
testCases := []struct {
58+
desc string
59+
mutate func(ip *v1.InferencePool)
60+
wantErrors []string
61+
}{
62+
{
63+
desc: "fails validation when kind is unset (defaults to Service) and port is missing",
64+
mutate: func(ip *v1.InferencePool) {
65+
// By setting Kind to an empty string, we rely on the API server's default value of "Service".
66+
ip.Spec.EndpointPickerRef.Kind = ""
67+
ip.Spec.EndpointPickerRef.Name = "vllm-llama3-8b-instruct-epp"
68+
ip.Spec.EndpointPickerRef.Port = nil
69+
},
70+
wantErrors: []string{"port is required when kind is 'Service'"},
71+
},
72+
{
73+
desc: "fails validation when kind is explicitly 'Service' and port is missing",
74+
mutate: func(ip *v1.InferencePool) {
75+
ip.Spec.EndpointPickerRef.Kind = "Service"
76+
ip.Spec.EndpointPickerRef.Name = "vllm-llama3-8b-instruct-epp"
77+
ip.Spec.EndpointPickerRef.Port = nil
78+
},
79+
wantErrors: []string{"port is required when kind is 'Service'"},
80+
},
81+
{
82+
desc: "passes validation when kind is 'Service' and port is present",
83+
mutate: func(ip *v1.InferencePool) {
84+
ip.Spec.EndpointPickerRef.Kind = "Service"
85+
ip.Spec.EndpointPickerRef.Name = "vllm-llama3-8b-instruct-epp"
86+
ip.Spec.EndpointPickerRef.Port = &v1.Port{
87+
Number: 9002,
88+
}
89+
},
90+
// No errors expected, so wantErrors is nil or empty.
91+
wantErrors: nil,
92+
},
93+
{
94+
desc: "passes validation with a valid, minimal configuration",
95+
mutate: func(ip *v1.InferencePool) {
96+
// This mutation just uses the base configuration, which should be valid.
97+
// It's a good sanity check. The base uses a non-Service Kind.
98+
},
99+
wantErrors: nil,
100+
},
101+
}
102+
103+
for _, tc := range testCases {
104+
t.Run(tc.desc, func(t *testing.T) {
105+
ip := baseInferencePool.DeepCopy()
106+
// Use a unique name for each test case to avoid conflicts.
107+
ip.Name = fmt.Sprintf("test-pool-%v", time.Now().UnixNano())
108+
109+
if tc.mutate != nil {
110+
tc.mutate(ip)
111+
}
112+
err := k8sClient.Create(ctx, ip)
113+
114+
// This is a boolean XOR. It's true if one is true, but not both.
115+
// It ensures that an error is returned if and only if we expect one.
116+
if (len(tc.wantErrors) != 0) != (err != nil) {
117+
t.Fatalf("Unexpected response while creating InferencePool; got err=\n%v\n; want error=%v", err, tc.wantErrors != nil)
118+
}
119+
120+
// If we got an error, check that it contains the expected substrings.
121+
var missingErrorStrings []string
122+
for _, wantError := range tc.wantErrors {
123+
if !celErrorStringMatches(err.Error(), wantError) {
124+
missingErrorStrings = append(missingErrorStrings, wantError)
125+
}
126+
}
127+
if len(missingErrorStrings) != 0 {
128+
t.Errorf("Unexpected response while creating InferencePool; got err=\n%v\n; missing strings within error=%q", err, missingErrorStrings)
129+
}
130+
})
131+
}
132+
}

test/cel/main_test.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/*
2+
Copyright 2023 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package main
18+
19+
import (
20+
"fmt"
21+
"os"
22+
"path/filepath"
23+
"strings"
24+
"testing"
25+
26+
corev1 "k8s.io/api/core/v1"
27+
"k8s.io/apimachinery/pkg/runtime"
28+
"k8s.io/client-go/rest"
29+
"k8s.io/client-go/tools/clientcmd"
30+
"sigs.k8s.io/controller-runtime/pkg/client"
31+
"sigs.k8s.io/controller-runtime/pkg/envtest"
32+
inferencev1 "sigs.k8s.io/gateway-api-inference-extension/api/v1"
33+
)
34+
35+
var k8sClient client.Client
36+
37+
func TestMain(m *testing.M) {
38+
scheme := runtime.NewScheme()
39+
var restConfig *rest.Config
40+
var testEnv *envtest.Environment
41+
var err error
42+
43+
inferencev1.AddToScheme(scheme)
44+
45+
// Add core APIs in case we refer secrets, services and configmaps
46+
corev1.AddToScheme(scheme)
47+
48+
// If one wants to use a local cluster, a KUBECONFIG envvar should be passed,
49+
// otherwise testenv will be used
50+
kubeconfig := os.Getenv("KUBECONFIG")
51+
if kubeconfig != "" {
52+
restConfig, err = clientcmd.BuildConfigFromFlags("", kubeconfig)
53+
if err != nil {
54+
panic(fmt.Sprintf("Failed to get restConfig from BuildConfigFromFlags: %v", err))
55+
}
56+
} else {
57+
// The version used here MUST reflect the available versions at
58+
// controller-runtime repo: https://raw.githubusercontent.com/kubernetes-sigs/controller-tools/HEAD/envtest-releases.yaml
59+
// If the envvar is not passed, the latest GA will be used
60+
k8sVersion := os.Getenv("K8S_VERSION")
61+
testEnv = &envtest.Environment{
62+
Scheme: scheme,
63+
ErrorIfCRDPathMissing: true,
64+
DownloadBinaryAssets: true,
65+
DownloadBinaryAssetsVersion: k8sVersion,
66+
CRDInstallOptions: envtest.CRDInstallOptions{
67+
Paths: []string{
68+
filepath.Join("..", "..", "..", "config", "crd", "bases"),
69+
},
70+
CleanUpAfterUse: true,
71+
},
72+
}
73+
74+
restConfig, err = testEnv.Start()
75+
if err != nil {
76+
panic(fmt.Sprintf("Error initializing test environment: %v", err))
77+
}
78+
}
79+
80+
k8sClient, err = client.New(restConfig, client.Options{
81+
Scheme: scheme,
82+
})
83+
if err != nil {
84+
panic(fmt.Sprintf("Error initializing Kubernetes client: %v", err))
85+
}
86+
87+
rc := m.Run()
88+
if testEnv != nil {
89+
if err := testEnv.Stop(); err != nil {
90+
panic(fmt.Sprintf("error stopping test environment: %v", err))
91+
}
92+
}
93+
94+
os.Exit(rc)
95+
}
96+
97+
func ptrTo[T any](a T) *T {
98+
return &a
99+
}
100+
101+
func celErrorStringMatches(got, want string) bool {
102+
gotL := strings.ToLower(got)
103+
wantL := strings.ToLower(want)
104+
105+
// Starting in k8s v1.32, some CEL error messages changed to use "more" instead of "longer"
106+
alternativeWantL := strings.ReplaceAll(wantL, "longer", "more")
107+
108+
// Starting in k8s v1.28, CEL error messages stopped adding spec and status prefixes to path names
109+
wantLAdjusted := strings.ReplaceAll(wantL, "spec.", "")
110+
wantLAdjusted = strings.ReplaceAll(wantLAdjusted, "status.", "")
111+
alternativeWantL = strings.ReplaceAll(alternativeWantL, "spec.", "")
112+
alternativeWantL = strings.ReplaceAll(alternativeWantL, "status.", "")
113+
114+
// Enum validation messages changed in k8s v1.28:
115+
// Before: must be one of ['Exact', 'PathPrefix', 'RegularExpression']
116+
// After: supported values: "Exact", "PathPrefix", "RegularExpression"
117+
if strings.Contains(wantLAdjusted, "must be one of") {
118+
r := strings.NewReplacer(
119+
"must be one of", "supported values:",
120+
"[", "",
121+
"]", "",
122+
"'", "\"",
123+
)
124+
wantLAdjusted = r.Replace(wantLAdjusted)
125+
}
126+
return strings.Contains(gotL, wantL) || strings.Contains(gotL, wantLAdjusted) || strings.Contains(gotL, alternativeWantL)
127+
}

0 commit comments

Comments
 (0)