Skip to content

Commit ce87793

Browse files
committed
feat(operator): split operator config contract across schema, validation, and canonical translation
Signed-off-by: zhoujinyu <2319109590@qq.com>
1 parent 73fad94 commit ce87793

30 files changed

+2920
-2328
lines changed

deploy/operator/api/v1alpha1/AGENTS.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@
77
## Responsibilities
88

99
- Keep CRD schema declaration, admission validation, and generated contract expectations distinct.
10-
- Treat `semanticrouter_types.go` as the schema hotspot and `semanticrouter_webhook.go` as the admission-validation hotspot, not as catch-all homes for every operator config change.
11-
- Keep sample and webhook regression tests aligned with the operator contract without pushing fixture-specific logic into production types.
10+
- Schema lives in `semanticrouter_types.go` plus `semanticrouter_types_*.go` family files; keep the root types file limited to `SemanticRouter` / `Spec` / `Status` CR wiring.
11+
- Admission: `semanticrouter_webhook.go` registers only; `semanticrouter_validate_deployment.go` covers infra (HPA, probes, ingress, persistence); `semanticrouter_validate_configspec*.go` covers `spec.config` by the same families as `semanticrouter_types_configspec.go`.
12+
- Keep sample and webhook regression tests aligned with the operator contract without pushing fixture-specific logic into production types. Register curated samples in `sample_fixtures_manifest_test.go` (`curatedOperatorSamples`).
1213

1314
## Change Rules
1415

1516
- Do not add controller-side canonical config translation logic into API type or webhook files.
16-
- When a spec family grows, prefer dedicated schema-family or validation-helper files over widening `semanticrouter_types.go` or `semanticrouter_webhook.go`.
17+
- When a spec family grows, extend the matching `semanticrouter_types_*.go`, `semanticrouter_validate_configspec_*.go`, and controller `canonical_config_operator_*.go` files instead of widening `semanticrouter_webhook.go` or the CR root types file.
1718
- Keep generated CRD, sample fixtures, and webhook tests aligned with the API contract in the same change; do not leave schema drift for a later patch.
1819
- If a change requires edits in both schema declaration and semantic validation, keep the files separate and make the shared contract seam explicit instead of widening one hotspot.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
Copyright 2026 vLLM Semantic Router Contributors.
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 v1alpha1
18+
19+
import "testing"
20+
21+
// configSpecFamily names the same seams as controller translation and configspec validators:
22+
// semanticrouter_types_configspec.go families, canonical_config_operator_*.go, semanticrouter_validate_configspec_*.go.
23+
type configSpecFamily string
24+
25+
const (
26+
familyModelCatalog configSpecFamily = "model_catalog"
27+
familyRouting configSpecFamily = "routing"
28+
familyStoresIntegrations configSpecFamily = "stores_integrations"
29+
familyDeploymentPlatform configSpecFamily = "deployment_platform"
30+
familyBaseline configSpecFamily = "baseline"
31+
)
32+
33+
type curatedOperatorSample struct {
34+
name string
35+
relPath string
36+
family configSpecFamily
37+
assert func(*testing.T, *SemanticRouter)
38+
}
39+
40+
// curatedOperatorSamples is the single manifest for CR samples exercised in validation tests.
41+
// Add new fixtures here with the same family as the schema/translation/validation file you extend.
42+
var curatedOperatorSamples = []curatedOperatorSample{
43+
{name: "simple sample CR", relPath: "vllm.ai_v1alpha1_semanticrouter_simple.yaml", family: familyBaseline, assert: nil},
44+
{name: "mmbert sample CR", relPath: "vllm.ai_v1alpha1_semanticrouter_mmbert.yaml", family: familyModelCatalog, assert: assertMmbertSampleSemantics},
45+
{name: "complexity routing sample CR", relPath: "vllm.ai_v1alpha1_semanticrouter_complexity.yaml", family: familyRouting, assert: assertComplexitySampleSemantics},
46+
{name: "redis cache sample CR", relPath: "vllm.ai_v1alpha1_semanticrouter_redis_cache.yaml", family: familyStoresIntegrations, assert: assertRedisCacheSampleSemantics},
47+
{name: "milvus cache sample CR", relPath: "vllm.ai_v1alpha1_semanticrouter_milvus_cache.yaml", family: familyStoresIntegrations, assert: assertMilvusCacheSampleSemantics},
48+
{name: "valkey cache sample CR", relPath: "vllm.ai_v1alpha1_semanticrouter_valkey_cache.yaml", family: familyStoresIntegrations, assert: nil},
49+
{name: "hybrid cache sample CR", relPath: "vllm.ai_v1alpha1_semanticrouter_hybrid_cache.yaml", family: familyStoresIntegrations, assert: nil},
50+
{name: "gateway sample CR", relPath: "vllm.ai_v1alpha1_semanticrouter_gateway.yaml", family: familyDeploymentPlatform, assert: nil},
51+
{name: "openshift sample CR", relPath: "vllm.ai_v1alpha1_semanticrouter_openshift.yaml", family: familyDeploymentPlatform, assert: nil},
52+
{name: "route sample CR", relPath: "vllm.ai_v1alpha1_semanticrouter_route.yaml", family: familyDeploymentPlatform, assert: nil},
53+
{name: "llamastack sample CR", relPath: "vllm.ai_v1alpha1_semanticrouter_llamastack.yaml", family: familyDeploymentPlatform, assert: nil},
54+
{name: "legacy vllm sample CR", relPath: "vllm_v1alpha1_semanticrouter.yaml", family: familyBaseline, assert: nil},
55+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/*
2+
Copyright 2026 vLLM Semantic Router Contributors.
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 v1alpha1
18+
19+
import (
20+
"testing"
21+
)
22+
23+
func assertMmbertSampleSemantics(t *testing.T, sr *SemanticRouter) {
24+
t.Helper()
25+
if sr.Spec.Config.EmbeddingModels == nil {
26+
t.Error("mmbert sample should have embedding_models configured")
27+
return
28+
}
29+
if sr.Spec.Config.EmbeddingModels.MmBertModelPath == "" {
30+
t.Error("mmbert sample should have mmbert_model_path set")
31+
}
32+
if sr.Spec.Config.EmbeddingModels.EmbeddingConfig == nil {
33+
t.Error("mmbert sample should have embedding_config")
34+
return
35+
}
36+
if sr.Spec.Config.EmbeddingModels.EmbeddingConfig.ModelType != "mmbert" {
37+
t.Errorf("mmbert sample embedding_config model_type = %v, want mmbert", sr.Spec.Config.EmbeddingModels.EmbeddingConfig.ModelType)
38+
}
39+
validLayers := map[int]bool{3: true, 6: true, 11: true, 22: true}
40+
if !validLayers[sr.Spec.Config.EmbeddingModels.EmbeddingConfig.TargetLayer] {
41+
t.Errorf("mmbert sample target_layer = %v, want one of 3, 6, 11, 22", sr.Spec.Config.EmbeddingModels.EmbeddingConfig.TargetLayer)
42+
}
43+
if sr.Spec.Config.SemanticCache != nil {
44+
if sr.Spec.Config.SemanticCache.EmbeddingModel != "mmbert" {
45+
t.Errorf("mmbert sample should use embedding_model: mmbert, got %v", sr.Spec.Config.SemanticCache.EmbeddingModel)
46+
}
47+
}
48+
}
49+
50+
func assertComplexitySampleSemantics(t *testing.T, sr *SemanticRouter) {
51+
t.Helper()
52+
if len(sr.Spec.Config.ComplexityRules) == 0 {
53+
t.Error("complexity sample should have complexity_rules configured")
54+
return
55+
}
56+
for _, rule := range sr.Spec.Config.ComplexityRules {
57+
if rule.Name == "" {
58+
t.Error("complexity rule should have a name")
59+
}
60+
if len(rule.Hard.Candidates) == 0 {
61+
t.Errorf("complexity rule %s should have hard candidates", rule.Name)
62+
}
63+
if len(rule.Easy.Candidates) == 0 {
64+
t.Errorf("complexity rule %s should have easy candidates", rule.Name)
65+
}
66+
}
67+
}
68+
69+
func assertRedisCacheSampleSemantics(t *testing.T, sr *SemanticRouter) {
70+
t.Helper()
71+
if sr.Spec.Config.EmbeddingModels == nil {
72+
t.Error("redis cache sample should have embedding_models configured")
73+
}
74+
if sr.Spec.Config.SemanticCache != nil {
75+
if sr.Spec.Config.SemanticCache.BackendType != "redis" {
76+
t.Errorf("redis cache sample backend_type = %v, want redis", sr.Spec.Config.SemanticCache.BackendType)
77+
}
78+
if sr.Spec.Config.SemanticCache.EmbeddingModel != "" && sr.Spec.Config.SemanticCache.EmbeddingModel == "bert" {
79+
t.Logf("redis cache sample could showcase new embedding models (qwen3/gemma)")
80+
}
81+
}
82+
}
83+
84+
func assertMilvusCacheSampleSemantics(t *testing.T, sr *SemanticRouter) {
85+
t.Helper()
86+
if sr.Spec.Config.EmbeddingModels == nil {
87+
t.Error("milvus cache sample should have embedding_models configured")
88+
}
89+
if sr.Spec.Config.SemanticCache != nil {
90+
if sr.Spec.Config.SemanticCache.BackendType != "milvus" {
91+
t.Errorf("milvus cache sample backend_type = %v, want milvus", sr.Spec.Config.SemanticCache.BackendType)
92+
}
93+
}
94+
}

deploy/operator/api/v1alpha1/sample_validation_test.go

Lines changed: 39 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -18,174 +18,86 @@ package v1alpha1
1818

1919
import (
2020
"context"
21+
"io/fs"
2122
"os"
2223
"path/filepath"
24+
"strings"
2325
"testing"
2426

2527
"k8s.io/apimachinery/pkg/util/yaml"
2628
)
2729

2830
func TestSampleCRValidation(t *testing.T) {
29-
// Get the project root directory
30-
projectRoot := filepath.Join("..", "..", "..")
31-
samplesDir := filepath.Join(projectRoot, "config", "samples")
32-
33-
tests := []struct {
34-
name string
35-
filename string
36-
}{
37-
{
38-
name: "mmbert sample CR",
39-
filename: "vllm.ai_v1alpha1_semanticrouter_mmbert.yaml",
40-
},
41-
{
42-
name: "complexity routing sample CR",
43-
filename: "vllm.ai_v1alpha1_semanticrouter_complexity.yaml",
44-
},
45-
{
46-
name: "simple sample CR",
47-
filename: "vllm.ai_v1alpha1_semanticrouter_simple.yaml",
48-
},
49-
{
50-
name: "redis cache sample CR",
51-
filename: "vllm.ai_v1alpha1_semanticrouter_redis_cache.yaml",
52-
},
53-
{
54-
name: "milvus cache sample CR",
55-
filename: "vllm.ai_v1alpha1_semanticrouter_milvus_cache.yaml",
56-
},
57-
}
58-
59-
for _, tt := range tests {
60-
t.Run(tt.name, func(t *testing.T) {
61-
samplePath := filepath.Join(samplesDir, tt.filename)
31+
samplesDir := operatorSamplesDir(t)
6232

63-
// Read the sample YAML file
33+
for _, fixture := range curatedOperatorSamples {
34+
fixture := fixture
35+
t.Run(fixture.name, func(t *testing.T) {
36+
samplePath := filepath.Join(samplesDir, fixture.relPath)
6437
data, err := os.ReadFile(samplePath)
6538
if err != nil {
66-
t.Skipf("Sample file not found: %s (this is expected during development)", samplePath)
39+
t.Skipf("Sample file not found: %s", samplePath)
6740
return
6841
}
6942

70-
// Parse the YAML into a SemanticRouter object
7143
var sr SemanticRouter
7244
if err := yaml.Unmarshal(data, &sr); err != nil {
7345
t.Errorf("Failed to unmarshal sample CR: %v", err)
7446
return
7547
}
7648

77-
// Validate the CR using webhook validation
7849
_, err = sr.ValidateCreate(context.Background(), &sr)
7950
if err != nil {
8051
t.Errorf("Sample CR validation failed: %v", err)
8152
}
8253

83-
// Additional checks for specific samples
84-
if tt.filename == "vllm.ai_v1alpha1_semanticrouter_mmbert.yaml" {
85-
if sr.Spec.Config.EmbeddingModels == nil {
86-
t.Error("mmbert sample should have embedding_models configured")
87-
} else {
88-
if sr.Spec.Config.EmbeddingModels.MmBertModelPath == "" {
89-
t.Error("mmbert sample should have mmbert_model_path set")
90-
}
91-
if sr.Spec.Config.EmbeddingModels.EmbeddingConfig == nil {
92-
t.Error("mmbert sample should have embedding_config")
93-
} else {
94-
if sr.Spec.Config.EmbeddingModels.EmbeddingConfig.ModelType != "mmbert" {
95-
t.Errorf("mmbert sample embedding_config model_type = %v, want mmbert", sr.Spec.Config.EmbeddingModels.EmbeddingConfig.ModelType)
96-
}
97-
// Verify target_layer is one of the valid values
98-
validLayers := map[int]bool{3: true, 6: true, 11: true, 22: true}
99-
if !validLayers[sr.Spec.Config.EmbeddingModels.EmbeddingConfig.TargetLayer] {
100-
t.Errorf("mmbert sample target_layer = %v, want one of 3, 6, 11, 22", sr.Spec.Config.EmbeddingModels.EmbeddingConfig.TargetLayer)
101-
}
102-
}
103-
}
104-
if sr.Spec.Config.SemanticCache != nil {
105-
if sr.Spec.Config.SemanticCache.EmbeddingModel != "mmbert" {
106-
t.Errorf("mmbert sample should use embedding_model: mmbert, got %v", sr.Spec.Config.SemanticCache.EmbeddingModel)
107-
}
108-
}
109-
}
110-
111-
if tt.filename == "vllm.ai_v1alpha1_semanticrouter_complexity.yaml" {
112-
if len(sr.Spec.Config.ComplexityRules) == 0 {
113-
t.Error("complexity sample should have complexity_rules configured")
114-
}
115-
// Verify structure of complexity rules
116-
for _, rule := range sr.Spec.Config.ComplexityRules {
117-
if rule.Name == "" {
118-
t.Error("complexity rule should have a name")
119-
}
120-
if len(rule.Hard.Candidates) == 0 {
121-
t.Errorf("complexity rule %s should have hard candidates", rule.Name)
122-
}
123-
if len(rule.Easy.Candidates) == 0 {
124-
t.Errorf("complexity rule %s should have easy candidates", rule.Name)
125-
}
126-
}
127-
}
128-
129-
if tt.filename == "vllm.ai_v1alpha1_semanticrouter_redis_cache.yaml" {
130-
if sr.Spec.Config.EmbeddingModels == nil {
131-
t.Error("redis cache sample should have embedding_models configured")
132-
}
133-
if sr.Spec.Config.SemanticCache != nil {
134-
if sr.Spec.Config.SemanticCache.BackendType != "redis" {
135-
t.Errorf("redis cache sample backend_type = %v, want redis", sr.Spec.Config.SemanticCache.BackendType)
136-
}
137-
// Should use qwen3 or another embedding model
138-
if sr.Spec.Config.SemanticCache.EmbeddingModel != "" && sr.Spec.Config.SemanticCache.EmbeddingModel == "bert" {
139-
// This is fine, but ideally should showcase new embedding models
140-
t.Logf("redis cache sample could showcase new embedding models (qwen3/gemma)")
141-
}
142-
}
143-
}
144-
145-
if tt.filename == "vllm.ai_v1alpha1_semanticrouter_milvus_cache.yaml" {
146-
if sr.Spec.Config.EmbeddingModels == nil {
147-
t.Error("milvus cache sample should have embedding_models configured")
148-
}
149-
if sr.Spec.Config.SemanticCache != nil {
150-
if sr.Spec.Config.SemanticCache.BackendType != "milvus" {
151-
t.Errorf("milvus cache sample backend_type = %v, want milvus", sr.Spec.Config.SemanticCache.BackendType)
152-
}
153-
}
54+
if fixture.assert != nil {
55+
fixture.assert(t, &sr)
15456
}
15557
})
15658
}
15759
}
15860

15961
func TestSampleCRsParseable(t *testing.T) {
160-
// Test that all sample CRs can be parsed without errors
161-
projectRoot := filepath.Join("..", "..", "..")
162-
samplesDir := filepath.Join(projectRoot, "config", "samples")
62+
samplesDir := operatorSamplesDir(t)
16363

164-
entries, err := os.ReadDir(samplesDir)
165-
if err != nil {
166-
t.Skipf("Samples directory not found: %s", samplesDir)
167-
return
168-
}
169-
170-
for _, entry := range entries {
171-
if entry.IsDir() {
172-
continue
64+
err := filepath.WalkDir(samplesDir, func(path string, d fs.DirEntry, walkErr error) error {
65+
if walkErr != nil {
66+
return walkErr
67+
}
68+
if d.IsDir() {
69+
return nil
17370
}
174-
if filepath.Ext(entry.Name()) != ".yaml" && filepath.Ext(entry.Name()) != ".yml" {
175-
continue
71+
ext := strings.ToLower(filepath.Ext(d.Name()))
72+
if ext != ".yaml" && ext != ".yml" {
73+
return nil
17674
}
17775

178-
t.Run(entry.Name(), func(t *testing.T) {
179-
samplePath := filepath.Join(samplesDir, entry.Name())
180-
data, err := os.ReadFile(samplePath)
76+
rel, err := filepath.Rel(samplesDir, path)
77+
if err != nil {
78+
return err
79+
}
80+
rel = filepath.ToSlash(rel)
81+
82+
t.Run(rel, func(t *testing.T) {
83+
data, err := os.ReadFile(path)
18184
if err != nil {
18285
t.Fatalf("Failed to read sample file: %v", err)
18386
}
184-
18587
var sr SemanticRouter
18688
if err := yaml.Unmarshal(data, &sr); err != nil {
187-
t.Errorf("Failed to parse sample CR %s: %v", entry.Name(), err)
89+
t.Errorf("Failed to parse sample CR %s: %v", rel, err)
18890
}
18991
})
92+
return nil
93+
})
94+
if err != nil {
95+
t.Fatalf("Walk samples: %v", err)
19096
}
19197
}
98+
99+
func operatorSamplesDir(t *testing.T) string {
100+
t.Helper()
101+
// Tests run with cwd = this package dir (api/v1alpha1); samples live under the operator module root.
102+
return filepath.Join("..", "..", "config", "samples")
103+
}

0 commit comments

Comments
 (0)