Skip to content

Commit 053bffb

Browse files
committed
Protected annotation-to-condition pattern
This patch introduces a feature that allows integration with VM conditions by external actors. Privileged users may set annotations on a VM that match the following pattern: condition.vmoperator.vmware.com.protected/UNIQUE_ID: TYPE[;STATUS][;REASON][;MESSAGE] Any annotations that match the above pattern are added/updated to a VM's conditions. Please note that STATUS must be either True, False, or Unknown. Any other value will be reported as Unknown. Also, there is currently no way to remove a condition via this method, only add/update them.
1 parent 72e5e0d commit 053bffb

File tree

4 files changed

+481
-46
lines changed

4 files changed

+481
-46
lines changed

pkg/providers/vsphere/vmlifecycle/update_status.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ func ReconcileStatus(
6363

6464
var errs []error
6565

66+
errs = append(errs, reconcileStatusAnno2Conditions(vmCtx, k8sClient, vcVM, data)...)
6667
errs = append(errs, reconcileStatusClass(vmCtx, k8sClient, vcVM, data)...)
6768
errs = append(errs, reconcileStatusPowerState(vmCtx, k8sClient, vcVM, data)...)
6869
errs = append(errs, reconcileStatusIdentifiers(vmCtx, k8sClient, vcVM, data)...)
@@ -91,6 +92,53 @@ func ReconcileStatus(
9192
return apierrorsutil.NewAggregate(errs)
9293
}
9394

95+
var anno2ConditionRegex = regexp.MustCompile(`^condition.vmoperator.vmware.com.protected/(.+)?$`)
96+
97+
// reconcileStatusAnno2Conditions sets conditions on the VM based on
98+
// annotation values.
99+
func reconcileStatusAnno2Conditions(
100+
vmCtx pkgctx.VirtualMachineContext,
101+
_ ctrlclient.Client,
102+
_ *object.VirtualMachine,
103+
_ ReconcileStatusData) []error { //nolint:unparam
104+
105+
for k, v := range vmCtx.VM.Annotations {
106+
if anno2ConditionRegex.MatchString(k) {
107+
var (
108+
t string
109+
s metav1.ConditionStatus
110+
r string
111+
m string
112+
)
113+
p := strings.Split(v, ";")
114+
if len(p) > 0 {
115+
t = p[0]
116+
}
117+
if len(p) > 1 {
118+
s = metav1.ConditionStatus(p[1])
119+
}
120+
if len(p) > 2 {
121+
r = p[2]
122+
}
123+
if len(p) > 3 {
124+
m = p[3]
125+
}
126+
if t != "" {
127+
switch s {
128+
case metav1.ConditionFalse:
129+
conditions.MarkFalse(vmCtx.VM, t, r, m+"%s", "")
130+
case metav1.ConditionTrue:
131+
conditions.MarkTrue(vmCtx.VM, t)
132+
default:
133+
conditions.MarkUnknown(vmCtx.VM, t, r, m+"%s", "")
134+
}
135+
}
136+
}
137+
}
138+
139+
return nil
140+
}
141+
94142
func reconcileStatusClass(
95143
vmCtx pkgctx.VirtualMachineContext,
96144
k8sClient ctrlclient.Client,

pkg/providers/vsphere/vmlifecycle/update_status_test.go

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,182 @@ var _ = Describe("UpdateStatus", func() {
106106
})
107107
})
108108

109+
Context("Annotations to Conditions", func() {
110+
const (
111+
testAnnotationKey = "condition.vmoperator.vmware.com.protected/MyCondition"
112+
testConditionType = "MyConditionType"
113+
testReason = "MyReason"
114+
testMessage = "My message"
115+
)
116+
117+
Context("When annotation matches the protected condition pattern", func() {
118+
When("annotation value has type and status=True", func() {
119+
BeforeEach(func() {
120+
vmCtx.VM.Annotations = map[string]string{
121+
testAnnotationKey: testConditionType + ";True",
122+
}
123+
})
124+
It("should set condition to True", func() {
125+
cond := conditions.Get(vmCtx.VM, testConditionType)
126+
Expect(cond).ToNot(BeNil())
127+
Expect(cond.Status).To(Equal(metav1.ConditionTrue))
128+
})
129+
})
130+
131+
When("annotation value has type and status=False with reason and message", func() {
132+
BeforeEach(func() {
133+
vmCtx.VM.Annotations = map[string]string{
134+
testAnnotationKey: testConditionType + ";False;" + testReason + ";" + testMessage,
135+
}
136+
})
137+
It("should set condition to False with reason and message", func() {
138+
cond := conditions.Get(vmCtx.VM, testConditionType)
139+
Expect(cond).ToNot(BeNil())
140+
Expect(cond.Status).To(Equal(metav1.ConditionFalse))
141+
Expect(cond.Reason).To(Equal(testReason))
142+
Expect(cond.Message).To(Equal(testMessage))
143+
})
144+
})
145+
146+
When("annotation value has type and status=Unknown with reason and message", func() {
147+
BeforeEach(func() {
148+
vmCtx.VM.Annotations = map[string]string{
149+
testAnnotationKey: testConditionType + ";Unknown;UnknownReason;Unknown message",
150+
}
151+
})
152+
It("should set condition to Unknown with reason and message", func() {
153+
cond := conditions.Get(vmCtx.VM, testConditionType)
154+
Expect(cond).ToNot(BeNil())
155+
Expect(cond.Status).To(Equal(metav1.ConditionUnknown))
156+
Expect(cond.Reason).To(Equal("UnknownReason"))
157+
Expect(cond.Message).To(Equal("Unknown message"))
158+
})
159+
})
160+
161+
When("annotation value has type and unrecognized status", func() {
162+
BeforeEach(func() {
163+
vmCtx.VM.Annotations = map[string]string{
164+
testAnnotationKey: testConditionType + ";InvalidStatus;" + testReason + ";" + testMessage,
165+
}
166+
})
167+
It("should set condition to Unknown", func() {
168+
cond := conditions.Get(vmCtx.VM, testConditionType)
169+
Expect(cond).ToNot(BeNil())
170+
Expect(cond.Status).To(Equal(metav1.ConditionUnknown))
171+
})
172+
})
173+
174+
When("annotation value has type but empty status", func() {
175+
BeforeEach(func() {
176+
vmCtx.VM.Annotations = map[string]string{
177+
testAnnotationKey: testConditionType + ";;" + testReason + ";" + testMessage,
178+
}
179+
})
180+
It("should set condition to Unknown", func() {
181+
cond := conditions.Get(vmCtx.VM, testConditionType)
182+
Expect(cond).ToNot(BeNil())
183+
Expect(cond.Status).To(Equal(metav1.ConditionUnknown))
184+
})
185+
})
186+
187+
When("annotation value has only type", func() {
188+
BeforeEach(func() {
189+
vmCtx.VM.Annotations = map[string]string{
190+
testAnnotationKey: testConditionType,
191+
}
192+
})
193+
It("should set condition to Unknown", func() {
194+
cond := conditions.Get(vmCtx.VM, testConditionType)
195+
Expect(cond).ToNot(BeNil())
196+
Expect(cond.Status).To(Equal(metav1.ConditionUnknown))
197+
})
198+
})
199+
200+
When("annotation value is empty", func() {
201+
BeforeEach(func() {
202+
vmCtx.VM.Annotations = map[string]string{
203+
testAnnotationKey: "",
204+
}
205+
})
206+
It("should not set any condition", func() {
207+
cond := conditions.Get(vmCtx.VM, "")
208+
Expect(cond).To(BeNil())
209+
})
210+
})
211+
212+
When("annotation value has no type (empty type)", func() {
213+
BeforeEach(func() {
214+
vmCtx.VM.Annotations = map[string]string{
215+
testAnnotationKey: ";True;" + testReason + ";" + testMessage,
216+
}
217+
})
218+
It("should not set a condition for empty type", func() {
219+
// Since type is empty, no condition with empty type should be set
220+
// The function checks if t != "" before setting conditions
221+
cond := conditions.Get(vmCtx.VM, "")
222+
Expect(cond).To(BeNil())
223+
// But Created condition should still exist
224+
cond = conditions.Get(vmCtx.VM, vmopv1.VirtualMachineConditionCreated)
225+
Expect(cond).ToNot(BeNil())
226+
})
227+
})
228+
229+
When("multiple protected condition annotations exist", func() {
230+
BeforeEach(func() {
231+
vmCtx.VM.Annotations = map[string]string{
232+
"condition.vmoperator.vmware.com.protected/Condition1": "Type1;True",
233+
"condition.vmoperator.vmware.com.protected/Condition2": "Type2;False;Reason2;Message2",
234+
"condition.vmoperator.vmware.com.protected/Condition3": "Type3;Unknown;Reason3;Message3",
235+
}
236+
})
237+
It("should set all conditions appropriately", func() {
238+
cond1 := conditions.Get(vmCtx.VM, "Type1")
239+
Expect(cond1).ToNot(BeNil())
240+
Expect(cond1.Status).To(Equal(metav1.ConditionTrue))
241+
242+
cond2 := conditions.Get(vmCtx.VM, "Type2")
243+
Expect(cond2).ToNot(BeNil())
244+
Expect(cond2.Status).To(Equal(metav1.ConditionFalse))
245+
Expect(cond2.Reason).To(Equal("Reason2"))
246+
Expect(cond2.Message).To(Equal("Message2"))
247+
248+
cond3 := conditions.Get(vmCtx.VM, "Type3")
249+
Expect(cond3).ToNot(BeNil())
250+
Expect(cond3.Status).To(Equal(metav1.ConditionUnknown))
251+
Expect(cond3.Reason).To(Equal("Reason3"))
252+
Expect(cond3.Message).To(Equal("Message3"))
253+
})
254+
})
255+
})
256+
257+
Context("When annotation does not match the protected condition pattern", func() {
258+
When("annotation has different prefix", func() {
259+
BeforeEach(func() {
260+
vmCtx.VM.Annotations = map[string]string{
261+
"other.annotation/MyCondition": testConditionType + ";True",
262+
}
263+
})
264+
It("should not set any condition from this annotation", func() {
265+
cond := conditions.Get(vmCtx.VM, testConditionType)
266+
Expect(cond).To(BeNil())
267+
})
268+
})
269+
270+
When("no annotations exist", func() {
271+
BeforeEach(func() {
272+
vmCtx.VM.Annotations = nil
273+
})
274+
It("should have default conditions set by ReconcileStatus", func() {
275+
// ReconcileStatus sets multiple conditions including Created and ReconcileReady
276+
// Just verify the Created condition exists and no annotation-based conditions
277+
cond := conditions.Get(vmCtx.VM, vmopv1.VirtualMachineConditionCreated)
278+
Expect(cond).ToNot(BeNil())
279+
Expect(cond.Status).To(Equal(metav1.ConditionTrue))
280+
})
281+
})
282+
})
283+
})
284+
109285
Context("Network", func() {
110286

111287
Context("PrimaryIP", func() {

webhooks/virtualmachine/validation/virtualmachine_validator.go

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1988,6 +1988,52 @@ func (v validator) validateAvailabilityZone(
19881988
return allErrs
19891989
}
19901990

1991+
// protectedAnnotationRegex matches annotations with keys matching the pattern:
1992+
// ^.+\.protected(/.+)?$
1993+
//
1994+
// Examples that match:
1995+
// - fu.bar.protected
1996+
// - hello.world.protected/sub-key
1997+
// - vmoperator.vmware.com.protected/reconcile-priority
1998+
//
1999+
// Examples that do NOT match:
2000+
// - protected.fu.bar
2001+
// - hello.world.protected.against/sub-key
2002+
var protectedAnnotationRegex = regexp.MustCompile(`^.+\.protected(/.*)?$`)
2003+
2004+
// validateProtectedAnnotations validates that annotations matching the
2005+
// protected annotation pattern can only be modified by privileged users.
2006+
func (v validator) validateProtectedAnnotations(vm, oldVM *vmopv1.VirtualMachine) field.ErrorList {
2007+
var allErrs field.ErrorList
2008+
annotationPath := field.NewPath("metadata", "annotations")
2009+
2010+
// Collect all protected annotation keys from both old and new VMs
2011+
protectedKeys := make(map[string]struct{})
2012+
2013+
for k := range vm.Annotations {
2014+
if protectedAnnotationRegex.MatchString(k) {
2015+
protectedKeys[k] = struct{}{}
2016+
}
2017+
}
2018+
2019+
for k := range oldVM.Annotations {
2020+
if protectedAnnotationRegex.MatchString(k) {
2021+
protectedKeys[k] = struct{}{}
2022+
}
2023+
}
2024+
2025+
// Check if any protected annotations have been modified
2026+
for k := range protectedKeys {
2027+
if vm.Annotations[k] != oldVM.Annotations[k] {
2028+
allErrs = append(allErrs, field.Forbidden(
2029+
annotationPath.Key(k),
2030+
modifyAnnotationNotAllowedForNonAdmin))
2031+
}
2032+
}
2033+
2034+
return allErrs
2035+
}
2036+
19912037
func (v validator) validateAnnotation(ctx *pkgctx.WebhookRequestContext, vm, oldVM *vmopv1.VirtualMachine) field.ErrorList {
19922038
var allErrs field.ErrorList
19932039

@@ -2012,9 +2058,7 @@ func (v validator) validateAnnotation(ctx *pkgctx.WebhookRequestContext, vm, old
20122058
oldVM = &vmopv1.VirtualMachine{}
20132059
}
20142060

2015-
if vm.Annotations[pkgconst.ReconcilePriorityAnnotationKey] != oldVM.Annotations[pkgconst.ReconcilePriorityAnnotationKey] {
2016-
allErrs = append(allErrs, field.Forbidden(annotationPath.Key(pkgconst.ReconcilePriorityAnnotationKey), modifyAnnotationNotAllowedForNonAdmin))
2017-
}
2061+
allErrs = append(allErrs, v.validateProtectedAnnotations(vm, oldVM)...)
20182062

20192063
if vm.Annotations[vmopv1.InstanceIDAnnotation] != oldVM.Annotations[vmopv1.InstanceIDAnnotation] {
20202064
allErrs = append(allErrs, field.Forbidden(annotationPath.Key(vmopv1.InstanceIDAnnotation), modifyAnnotationNotAllowedForNonAdmin))
@@ -2036,14 +2080,6 @@ func (v validator) validateAnnotation(ctx *pkgctx.WebhookRequestContext, vm, old
20362080
allErrs = append(allErrs, field.Forbidden(annotationPath.Key(vmopv1.ImportedVMAnnotation), modifyAnnotationNotAllowedForNonAdmin))
20372081
}
20382082

2039-
if vm.Annotations[pkgconst.SkipDeletePlatformResourceKey] != oldVM.Annotations[pkgconst.SkipDeletePlatformResourceKey] {
2040-
allErrs = append(allErrs, field.Forbidden(annotationPath.Key(pkgconst.SkipDeletePlatformResourceKey), modifyAnnotationNotAllowedForNonAdmin))
2041-
}
2042-
2043-
if vm.Annotations[pkgconst.ApplyPowerStateTimeAnnotation] != oldVM.Annotations[pkgconst.ApplyPowerStateTimeAnnotation] {
2044-
allErrs = append(allErrs, field.Forbidden(annotationPath.Key(pkgconst.ApplyPowerStateTimeAnnotation), modifyAnnotationNotAllowedForNonAdmin))
2045-
}
2046-
20472083
for k := range anno2extraconfig.AnnotationsToExtraConfigKeys {
20482084
if vm.Annotations[k] != oldVM.Annotations[k] {
20492085
allErrs = append(allErrs, field.Forbidden(annotationPath.Key(k), modifyAnnotationNotAllowedForNonAdmin))

0 commit comments

Comments
 (0)