Skip to content

Commit 6a05429

Browse files
syscod3claude
andcommitted
feat(webhook): ImpVM defaulter + validator
Add ImpVMWebhook implementing admission.Defaulter[*ImpVM] and admission.Validator[*ImpVM]: defaults lifecycle to ephemeral, validates templateRef/classRef mutual exclusion and image requirement, and enforces nodeName immutability on update. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f8dffc5 commit 6a05429

2 files changed

Lines changed: 268 additions & 0 deletions

File tree

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/*
2+
Copyright 2026.
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+
"context"
21+
22+
"k8s.io/apimachinery/pkg/util/validation/field"
23+
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
24+
25+
impdevv1alpha1 "github.com/syscode-labs/imp/api/v1alpha1"
26+
)
27+
28+
// ImpVMWebhook implements defaulting and validation for ImpVM.
29+
type ImpVMWebhook struct{}
30+
31+
// Default implements admission.Defaulter[*impdevv1alpha1.ImpVM].
32+
func (w *ImpVMWebhook) Default(_ context.Context, vm *impdevv1alpha1.ImpVM) error {
33+
if vm.Spec.Lifecycle == "" {
34+
vm.Spec.Lifecycle = impdevv1alpha1.VMLifecycleEphemeral
35+
}
36+
return nil
37+
}
38+
39+
// ValidateCreate implements admission.Validator[*impdevv1alpha1.ImpVM].
40+
func (w *ImpVMWebhook) ValidateCreate(_ context.Context, vm *impdevv1alpha1.ImpVM) (admission.Warnings, error) {
41+
return nil, validateImpVM(vm).ToAggregate()
42+
}
43+
44+
// ValidateUpdate implements admission.Validator[*impdevv1alpha1.ImpVM].
45+
func (w *ImpVMWebhook) ValidateUpdate(_ context.Context, oldVM, newVM *impdevv1alpha1.ImpVM) (admission.Warnings, error) {
46+
errs := validateImpVM(newVM)
47+
48+
if oldVM.Spec.NodeName != "" && newVM.Spec.NodeName != oldVM.Spec.NodeName {
49+
errs = append(errs, field.Forbidden(
50+
field.NewPath("spec", "nodeName"),
51+
"nodeName is immutable once set",
52+
))
53+
}
54+
55+
return nil, errs.ToAggregate()
56+
}
57+
58+
// ValidateDelete implements admission.Validator[*impdevv1alpha1.ImpVM].
59+
func (w *ImpVMWebhook) ValidateDelete(_ context.Context, _ *impdevv1alpha1.ImpVM) (admission.Warnings, error) {
60+
return nil, nil
61+
}
62+
63+
// validateImpVM checks the spec invariants shared by create and update.
64+
func validateImpVM(vm *impdevv1alpha1.ImpVM) field.ErrorList {
65+
var errs field.ErrorList
66+
67+
hasTemplate := vm.Spec.TemplateRef != nil
68+
hasClass := vm.Spec.ClassRef != nil
69+
70+
switch {
71+
case hasTemplate && hasClass:
72+
errs = append(errs, field.Invalid(
73+
field.NewPath("spec", "classRef"),
74+
vm.Spec.ClassRef,
75+
"classRef and templateRef are mutually exclusive",
76+
))
77+
case !hasTemplate && !hasClass:
78+
errs = append(errs, field.Required(
79+
field.NewPath("spec", "classRef"),
80+
"exactly one of classRef or templateRef must be set",
81+
))
82+
case hasClass && !hasTemplate && vm.Spec.Image == "":
83+
errs = append(errs, field.Required(
84+
field.NewPath("spec", "image"),
85+
"image is required when classRef is set without templateRef",
86+
))
87+
}
88+
89+
return errs
90+
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
/*
2+
Copyright 2026.
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+
"context"
21+
"testing"
22+
23+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
24+
25+
impdevv1alpha1 "github.com/syscode-labs/imp/api/v1alpha1"
26+
)
27+
28+
// newVM builds a minimal ImpVM for use in tests.
29+
// Pass empty string for templateRef, classRef, or image to leave them unset.
30+
func newVM(templateRef, classRef, image string) *impdevv1alpha1.ImpVM {
31+
vm := &impdevv1alpha1.ImpVM{
32+
ObjectMeta: metav1.ObjectMeta{
33+
Name: "test-vm",
34+
Namespace: "default",
35+
},
36+
}
37+
if templateRef != "" {
38+
vm.Spec.TemplateRef = &impdevv1alpha1.LocalObjectRef{Name: templateRef}
39+
}
40+
if classRef != "" {
41+
vm.Spec.ClassRef = &impdevv1alpha1.ClusterObjectRef{Name: classRef}
42+
}
43+
vm.Spec.Image = image
44+
return vm
45+
}
46+
47+
// --- Defaulter tests -------------------------------------------------------
48+
49+
func TestImpVMWebhook_Default_SetsLifecycle(t *testing.T) {
50+
wh := &ImpVMWebhook{}
51+
vm := newVM("", "my-class", "my-image")
52+
// lifecycle is empty
53+
54+
if err := wh.Default(context.Background(), vm); err != nil {
55+
t.Fatalf("Default() returned unexpected error: %v", err)
56+
}
57+
if vm.Spec.Lifecycle != impdevv1alpha1.VMLifecycleEphemeral {
58+
t.Errorf("expected lifecycle=%q, got %q", impdevv1alpha1.VMLifecycleEphemeral, vm.Spec.Lifecycle)
59+
}
60+
}
61+
62+
func TestImpVMWebhook_Default_PreservesExistingLifecycle(t *testing.T) {
63+
wh := &ImpVMWebhook{}
64+
vm := newVM("", "my-class", "my-image")
65+
vm.Spec.Lifecycle = impdevv1alpha1.VMLifecyclePersistent
66+
67+
if err := wh.Default(context.Background(), vm); err != nil {
68+
t.Fatalf("Default() returned unexpected error: %v", err)
69+
}
70+
if vm.Spec.Lifecycle != impdevv1alpha1.VMLifecyclePersistent {
71+
t.Errorf("expected lifecycle=%q, got %q", impdevv1alpha1.VMLifecyclePersistent, vm.Spec.Lifecycle)
72+
}
73+
}
74+
75+
// --- ValidateCreate tests --------------------------------------------------
76+
77+
func TestImpVMWebhook_ValidateCreate_BothRefs(t *testing.T) {
78+
wh := &ImpVMWebhook{}
79+
vm := newVM("my-template", "my-class", "my-image")
80+
81+
_, err := wh.ValidateCreate(context.Background(), vm)
82+
if err == nil {
83+
t.Fatal("expected error for both templateRef and classRef set, got nil")
84+
}
85+
}
86+
87+
func TestImpVMWebhook_ValidateCreate_NoRefs(t *testing.T) {
88+
wh := &ImpVMWebhook{}
89+
vm := newVM("", "", "")
90+
91+
_, err := wh.ValidateCreate(context.Background(), vm)
92+
if err == nil {
93+
t.Fatal("expected error for neither templateRef nor classRef set, got nil")
94+
}
95+
}
96+
97+
func TestImpVMWebhook_ValidateCreate_ClassRefWithoutImage(t *testing.T) {
98+
wh := &ImpVMWebhook{}
99+
vm := newVM("", "my-class", "") // classRef set, image empty
100+
101+
_, err := wh.ValidateCreate(context.Background(), vm)
102+
if err == nil {
103+
t.Fatal("expected error when classRef is set without image, got nil")
104+
}
105+
}
106+
107+
func TestImpVMWebhook_ValidateCreate_Valid_ClassRef(t *testing.T) {
108+
wh := &ImpVMWebhook{}
109+
vm := newVM("", "my-class", "my-image")
110+
111+
_, err := wh.ValidateCreate(context.Background(), vm)
112+
if err != nil {
113+
t.Errorf("expected no error for valid classRef+image, got: %v", err)
114+
}
115+
}
116+
117+
func TestImpVMWebhook_ValidateCreate_Valid_TemplateRef(t *testing.T) {
118+
wh := &ImpVMWebhook{}
119+
vm := newVM("my-template", "", "") // templateRef only, no image required
120+
121+
_, err := wh.ValidateCreate(context.Background(), vm)
122+
if err != nil {
123+
t.Errorf("expected no error for valid templateRef, got: %v", err)
124+
}
125+
}
126+
127+
// --- ValidateUpdate tests --------------------------------------------------
128+
129+
func TestImpVMWebhook_ValidateUpdate_NodeNameImmutable(t *testing.T) {
130+
wh := &ImpVMWebhook{}
131+
oldVM := newVM("my-template", "", "")
132+
oldVM.Spec.NodeName = "node-1"
133+
newVM := newVM("my-template", "", "")
134+
newVM.Spec.NodeName = "node-2"
135+
136+
_, err := wh.ValidateUpdate(context.Background(), oldVM, newVM)
137+
if err == nil {
138+
t.Fatal("expected error when nodeName is changed after being set, got nil")
139+
}
140+
}
141+
142+
func TestImpVMWebhook_ValidateUpdate_NodeNameCanBeSetFromEmpty(t *testing.T) {
143+
wh := &ImpVMWebhook{}
144+
oldVM := newVM("my-template", "", "")
145+
oldVM.Spec.NodeName = ""
146+
newVM := newVM("my-template", "", "")
147+
newVM.Spec.NodeName = "node-1"
148+
149+
_, err := wh.ValidateUpdate(context.Background(), oldVM, newVM)
150+
if err != nil {
151+
t.Errorf("expected no error when setting nodeName from empty, got: %v", err)
152+
}
153+
}
154+
155+
func TestImpVMWebhook_ValidateUpdate_NodeNameUnchanged(t *testing.T) {
156+
wh := &ImpVMWebhook{}
157+
oldVM := newVM("my-template", "", "")
158+
oldVM.Spec.NodeName = "node-1"
159+
newVM := newVM("my-template", "", "")
160+
newVM.Spec.NodeName = "node-1"
161+
162+
_, err := wh.ValidateUpdate(context.Background(), oldVM, newVM)
163+
if err != nil {
164+
t.Errorf("expected no error when nodeName is unchanged, got: %v", err)
165+
}
166+
}
167+
168+
// --- ValidateDelete tests --------------------------------------------------
169+
170+
func TestImpVMWebhook_ValidateDelete(t *testing.T) {
171+
wh := &ImpVMWebhook{}
172+
vm := newVM("my-template", "", "")
173+
174+
_, err := wh.ValidateDelete(context.Background(), vm)
175+
if err != nil {
176+
t.Errorf("expected no error on delete, got: %v", err)
177+
}
178+
}

0 commit comments

Comments
 (0)