Skip to content

Commit 830f623

Browse files
committed
Make the hypervisor spec immutable on termination
It is hard to undo the termination in certain situations and the field should usually be set by an automation, so uncommiting to terminate a node is a rather less common scenario. So let's prohibit it for now. HA events can always happen, so we still need to be able to switch to that, and then anything goes.
1 parent 0e26bc8 commit 830f623

File tree

5 files changed

+291
-0
lines changed

5 files changed

+291
-0
lines changed

api/v1/hypervisor_types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ const (
6868
)
6969

7070
// HypervisorSpec defines the desired state of Hypervisor
71+
// +kubebuilder:validation:XValidation:rule="!has(oldSelf.maintenance) || oldSelf.maintenance != 'termination' || self.maintenance == 'ha' || self == oldSelf",message="spec is immutable when maintenance is 'termination'; can only change maintenance to 'ha'"
7172
type HypervisorSpec struct {
7273
// +kubebuilder:validation:Optional
7374
// OperatingSystemVersion represents the desired operating system version.
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
/*
2+
SPDX-FileCopyrightText: Copyright 2024 SAP SE or an SAP affiliate company and cobaltcore-dev contributors
3+
SPDX-License-Identifier: Apache-2.0
4+
5+
Licensed under the Apache License, Version 2.0 (the "License");
6+
you may not use this file except in compliance with the License.
7+
You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
18+
package v1
19+
20+
import (
21+
. "github.com/onsi/ginkgo/v2"
22+
. "github.com/onsi/gomega"
23+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
24+
"k8s.io/apimachinery/pkg/types"
25+
"sigs.k8s.io/controller-runtime/pkg/client"
26+
)
27+
28+
// TestHypervisorSpecValidation tests the CEL validation rule for HypervisorSpec
29+
// The rule: oldSelf.maintenance != 'termination' || self.maintenance == 'ha' || self == oldSelf
30+
// This ensures that when maintenance is set to 'termination', the spec cannot be modified
31+
// unless the maintenance field is changed to 'ha'
32+
var _ = Describe("Hypervisor Spec CEL Validation", func() {
33+
var (
34+
hypervisor *Hypervisor
35+
hypervisorName types.NamespacedName
36+
)
37+
38+
BeforeEach(func(ctx SpecContext) {
39+
hypervisorName = types.NamespacedName{
40+
Name: "test-hypervisor",
41+
}
42+
43+
hypervisor = &Hypervisor{
44+
ObjectMeta: metav1.ObjectMeta{
45+
Name: hypervisorName.Name,
46+
},
47+
Spec: HypervisorSpec{
48+
OperatingSystemVersion: "1.0",
49+
LifecycleEnabled: true,
50+
HighAvailability: true,
51+
EvacuateOnReboot: true,
52+
InstallCertificate: true,
53+
Maintenance: MaintenanceManual,
54+
},
55+
}
56+
57+
Expect(k8sClient.Create(ctx, hypervisor)).To(Succeed())
58+
59+
DeferCleanup(func(ctx SpecContext) {
60+
Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, hypervisor))).To(Succeed())
61+
})
62+
})
63+
64+
Context("When maintenance is NOT termination", func() {
65+
It("should allow changes to any spec fields", func(ctx SpecContext) {
66+
// Update version and other fields
67+
hypervisor.Spec.OperatingSystemVersion = "2.0"
68+
hypervisor.Spec.HighAvailability = false
69+
Expect(k8sClient.Update(ctx, hypervisor)).To(Succeed())
70+
71+
// Verify the update was successful
72+
updated := &Hypervisor{}
73+
Expect(k8sClient.Get(ctx, hypervisorName, updated)).To(Succeed())
74+
Expect(updated.Spec.OperatingSystemVersion).To(Equal("2.0"))
75+
Expect(updated.Spec.HighAvailability).To(BeFalse())
76+
})
77+
78+
It("should allow changing maintenance to termination", func(ctx SpecContext) {
79+
hypervisor.Spec.Maintenance = MaintenanceTermination
80+
Expect(k8sClient.Update(ctx, hypervisor)).To(Succeed())
81+
82+
updated := &Hypervisor{}
83+
Expect(k8sClient.Get(ctx, hypervisorName, updated)).To(Succeed())
84+
Expect(updated.Spec.Maintenance).To(Equal(MaintenanceTermination))
85+
})
86+
})
87+
88+
Context("When maintenance IS termination", func() {
89+
BeforeEach(func(ctx SpecContext) {
90+
// Set maintenance to termination
91+
hypervisor.Spec.Maintenance = MaintenanceTermination
92+
Expect(k8sClient.Update(ctx, hypervisor)).To(Succeed())
93+
94+
// Refresh to get latest version
95+
Expect(k8sClient.Get(ctx, hypervisorName, hypervisor)).To(Succeed())
96+
})
97+
98+
It("should reject changes to version field", func(ctx SpecContext) {
99+
hypervisor.Spec.OperatingSystemVersion = "2.0"
100+
err := k8sClient.Update(ctx, hypervisor)
101+
Expect(err).To(HaveOccurred())
102+
Expect(err.Error()).To(ContainSubstring("spec is immutable when maintenance is 'termination'"))
103+
})
104+
105+
It("should reject changes to boolean fields", func(ctx SpecContext) {
106+
hypervisor.Spec.LifecycleEnabled = false
107+
err := k8sClient.Update(ctx, hypervisor)
108+
Expect(err).To(HaveOccurred())
109+
Expect(err.Error()).To(ContainSubstring("spec is immutable when maintenance is 'termination'"))
110+
})
111+
112+
It("should reject changes to array fields", func(ctx SpecContext) {
113+
hypervisor.Spec.CustomTraits = []string{"new-trait"}
114+
err := k8sClient.Update(ctx, hypervisor)
115+
Expect(err).To(HaveOccurred())
116+
Expect(err.Error()).To(ContainSubstring("spec is immutable when maintenance is 'termination'"))
117+
})
118+
119+
It("should reject changing maintenance to manual", func(ctx SpecContext) {
120+
hypervisor.Spec.Maintenance = MaintenanceManual
121+
err := k8sClient.Update(ctx, hypervisor)
122+
Expect(err).To(HaveOccurred())
123+
Expect(err.Error()).To(ContainSubstring("spec is immutable when maintenance is 'termination'"))
124+
})
125+
126+
It("should reject changing maintenance to auto", func(ctx SpecContext) {
127+
hypervisor.Spec.Maintenance = MaintenanceAuto
128+
err := k8sClient.Update(ctx, hypervisor)
129+
Expect(err).To(HaveOccurred())
130+
Expect(err.Error()).To(ContainSubstring("spec is immutable when maintenance is 'termination'"))
131+
})
132+
133+
It("should reject changing maintenance to empty", func(ctx SpecContext) {
134+
hypervisor.Spec.Maintenance = MaintenanceUnset
135+
err := k8sClient.Update(ctx, hypervisor)
136+
Expect(err).To(HaveOccurred())
137+
Expect(err.Error()).To(ContainSubstring("spec is immutable when maintenance is 'termination'"))
138+
})
139+
140+
It("should allow changing maintenance to ha", func(ctx SpecContext) {
141+
hypervisor.Spec.Maintenance = MaintenanceHA
142+
Expect(k8sClient.Update(ctx, hypervisor)).To(Succeed())
143+
144+
updated := &Hypervisor{}
145+
Expect(k8sClient.Get(ctx, hypervisorName, updated)).To(Succeed())
146+
Expect(updated.Spec.Maintenance).To(Equal(MaintenanceHA))
147+
})
148+
149+
It("should allow changing maintenance to ha with other spec changes", func(ctx SpecContext) {
150+
hypervisor.Spec.Maintenance = MaintenanceHA
151+
hypervisor.Spec.OperatingSystemVersion = "2.0"
152+
hypervisor.Spec.LifecycleEnabled = false
153+
Expect(k8sClient.Update(ctx, hypervisor)).To(Succeed())
154+
155+
updated := &Hypervisor{}
156+
Expect(k8sClient.Get(ctx, hypervisorName, updated)).To(Succeed())
157+
Expect(updated.Spec.Maintenance).To(Equal(MaintenanceHA))
158+
Expect(updated.Spec.OperatingSystemVersion).To(Equal("2.0"))
159+
Expect(updated.Spec.LifecycleEnabled).To(BeFalse())
160+
})
161+
162+
It("should allow no-op updates (no changes)", func(ctx SpecContext) {
163+
// Update with same values should succeed
164+
Expect(k8sClient.Update(ctx, hypervisor)).To(Succeed())
165+
})
166+
})
167+
168+
Context("When creating a new Hypervisor", func() {
169+
It("should allow creation with maintenance set to termination", func(ctx SpecContext) {
170+
newHypervisor := &Hypervisor{
171+
ObjectMeta: metav1.ObjectMeta{
172+
Name: "new-test-hypervisor",
173+
},
174+
Spec: HypervisorSpec{
175+
Maintenance: MaintenanceTermination,
176+
OperatingSystemVersion: "1.0",
177+
LifecycleEnabled: true,
178+
HighAvailability: true,
179+
EvacuateOnReboot: true,
180+
InstallCertificate: true,
181+
},
182+
}
183+
184+
Expect(k8sClient.Create(ctx, newHypervisor)).To(Succeed())
185+
186+
DeferCleanup(func(ctx SpecContext) {
187+
Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, newHypervisor))).To(Succeed())
188+
})
189+
190+
created := &Hypervisor{}
191+
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "new-test-hypervisor"}, created)).To(Succeed())
192+
Expect(created.Spec.Maintenance).To(Equal(MaintenanceTermination))
193+
})
194+
})
195+
})
196+
197+
// TestMaintenanceConstants verifies the maintenance mode constants
198+
var _ = Describe("Maintenance Constants", func() {
199+
It("should have correct constant values", func() {
200+
Expect(MaintenanceUnset).To(Equal(""))
201+
Expect(MaintenanceManual).To(Equal("manual"))
202+
Expect(MaintenanceAuto).To(Equal("auto"))
203+
Expect(MaintenanceHA).To(Equal("ha"))
204+
Expect(MaintenanceTermination).To(Equal("termination"))
205+
})
206+
})

api/v1/suite_test.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/*
2+
SPDX-FileCopyrightText: Copyright 2024 SAP SE or an SAP affiliate company and cobaltcore-dev contributors
3+
SPDX-License-Identifier: Apache-2.0
4+
5+
Licensed under the Apache License, Version 2.0 (the "License");
6+
you may not use this file except in compliance with the License.
7+
You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
*/
17+
18+
package v1
19+
20+
import (
21+
"fmt"
22+
"path/filepath"
23+
"runtime"
24+
"testing"
25+
26+
. "github.com/onsi/ginkgo/v2"
27+
. "github.com/onsi/gomega"
28+
29+
"k8s.io/client-go/kubernetes/scheme"
30+
"k8s.io/client-go/rest"
31+
"sigs.k8s.io/controller-runtime/pkg/client"
32+
"sigs.k8s.io/controller-runtime/pkg/envtest"
33+
logf "sigs.k8s.io/controller-runtime/pkg/log"
34+
"sigs.k8s.io/controller-runtime/pkg/log/zap"
35+
)
36+
37+
var cfg *rest.Config
38+
var k8sClient client.Client
39+
var testEnv *envtest.Environment
40+
41+
func TestAPIs(t *testing.T) {
42+
RegisterFailHandler(Fail)
43+
RunSpecs(t, "API v1 Suite")
44+
}
45+
46+
var _ = BeforeSuite(func() {
47+
logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true)))
48+
49+
By("bootstrapping test environment")
50+
testEnv = &envtest.Environment{
51+
CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")},
52+
ErrorIfCRDPathMissing: true,
53+
BinaryAssetsDirectory: filepath.Join("..", "..", "bin", "k8s",
54+
fmt.Sprintf("1.31.0-%s-%s", runtime.GOOS, runtime.GOARCH)),
55+
}
56+
57+
var err error
58+
cfg, err = testEnv.Start()
59+
Expect(err).NotTo(HaveOccurred())
60+
Expect(cfg).NotTo(BeNil())
61+
62+
err = AddToScheme(scheme.Scheme)
63+
Expect(err).NotTo(HaveOccurred())
64+
65+
k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme})
66+
Expect(err).NotTo(HaveOccurred())
67+
Expect(k8sClient).NotTo(BeNil())
68+
})
69+
70+
var _ = AfterSuite(func() {
71+
By("tearing down the test environment")
72+
err := testEnv.Stop()
73+
Expect(err).NotTo(HaveOccurred())
74+
})

charts/openstack-hypervisor-operator/crds/hypervisor-crd.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,11 @@ spec:
185185
- reboot
186186
- skipTests
187187
type: object
188+
x-kubernetes-validations:
189+
- message: spec is immutable when maintenance is 'termination'; can only
190+
change maintenance to 'ha'
191+
rule: '!has(oldSelf.maintenance) || oldSelf.maintenance != ''termination''
192+
|| self.maintenance == ''ha'' || self == oldSelf'
188193
status:
189194
description: HypervisorStatus defines the observed state of Hypervisor
190195
properties:

config/crd/bases/kvm.cloud.sap_hypervisors.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,11 @@ spec:
186186
- reboot
187187
- skipTests
188188
type: object
189+
x-kubernetes-validations:
190+
- message: spec is immutable when maintenance is 'termination'; can only
191+
change maintenance to 'ha'
192+
rule: '!has(oldSelf.maintenance) || oldSelf.maintenance != ''termination''
193+
|| self.maintenance == ''ha'' || self == oldSelf'
189194
status:
190195
description: HypervisorStatus defines the observed state of Hypervisor
191196
properties:

0 commit comments

Comments
 (0)