Skip to content

Commit 9890452

Browse files
committed
controller: add simple rollout envtest
Simulates a 3-node rollout where all nodes have staged the target image. With maxUnavailable: 1, verifies that only one node gets a reboot slot (cordoned, annotated) at a time. This is pretty simple for now because we only have all the rollout logic partially implemented. As we add more of the missing pieces, we can keep extending this test to be more complete. Assisted-by: Pi (Claude Opus 4.6)
1 parent ee5e842 commit 9890452

2 files changed

Lines changed: 151 additions & 0 deletions

File tree

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package controller
4+
5+
import (
6+
"context"
7+
"testing"
8+
9+
. "github.com/onsi/gomega"
10+
corev1 "k8s.io/api/core/v1"
11+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
12+
"k8s.io/apimachinery/pkg/util/intstr"
13+
"sigs.k8s.io/controller-runtime/pkg/client"
14+
15+
bootcv1alpha1 "github.com/jlebon/bootc-operator/api/v1alpha1"
16+
testutil "github.com/jlebon/bootc-operator/test/util"
17+
)
18+
19+
// TestSimpleRollout simulates a 3-node rollout with maxUnavailable: 1. It
20+
// verifies that only one node is cordoned at a time and that desiredImageState
21+
// is set to Booted after drain completes.
22+
func TestSimpleRollout(t *testing.T) {
23+
g := NewWithT(t)
24+
g.SetDefaultEventuallyTimeout(pollTimeout)
25+
g.SetDefaultEventuallyPollingInterval(pollInterval)
26+
ctx := context.Background()
27+
28+
const (
29+
poolName = "rollout-3node"
30+
// All nodes are booting digest B; pool targets digest A.
31+
targetRef = testImageDigestRefA
32+
targetImage = testImageDigestRefA
33+
bootedImage = testImageDigestRefB
34+
)
35+
36+
// Create 3 worker nodes.
37+
nodeNames := []string{"rollout-w1", "rollout-w2", "rollout-w3"}
38+
for _, name := range nodeNames {
39+
name := name
40+
node := testutil.NewK8sNode(name, testutil.WorkerLabels())
41+
g.Expect(k8sClient.Create(ctx, node)).To(Succeed())
42+
t.Cleanup(func() {
43+
_ = k8sClient.Delete(ctx, node)
44+
})
45+
}
46+
47+
// Create pool targeting digest A with maxUnavailable: 1.
48+
pool := testutil.NewPool(poolName, targetRef,
49+
testutil.WithWorkerSelector(),
50+
testutil.WithMaxUnavailable(intstr.FromInt32(1)),
51+
)
52+
g.Expect(k8sClient.Create(ctx, pool)).To(Succeed())
53+
t.Cleanup(func() {
54+
_ = k8sClient.Delete(ctx, pool)
55+
})
56+
57+
// Wait for BootcNodes to be created.
58+
for _, name := range nodeNames {
59+
name := name
60+
g.Eventually(func() error {
61+
return k8sClient.Get(ctx, client.ObjectKey{Name: name}, &bootcv1alpha1.BootcNode{})
62+
}).Should(Succeed())
63+
}
64+
65+
// Simulate daemon: set all nodes as booting the old image and Staged
66+
// for the new one. This is the state where nodes have staged the
67+
// target image and are waiting for a reboot slot.
68+
for _, name := range nodeNames {
69+
simulateDaemonStatus(g, ctx, name, testDigestB, bootcv1alpha1.NodeReasonStaged)
70+
}
71+
72+
// Wait for exactly one node to be cordoned (reboot slot assigned).
73+
var cordonedNode string
74+
g.Eventually(func() string {
75+
cordonedNode = ""
76+
for _, name := range nodeNames {
77+
var node corev1.Node
78+
g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: name}, &node)).To(Succeed())
79+
if node.Spec.Unschedulable {
80+
if cordonedNode != "" {
81+
// More than one node cordoned — fail.
82+
return "MULTIPLE"
83+
}
84+
cordonedNode = name
85+
}
86+
}
87+
return cordonedNode
88+
}).ShouldNot(BeEmpty())
89+
g.Expect(cordonedNode).NotTo(Equal("MULTIPLE"), "only one node should be cordoned with maxUnavailable: 1")
90+
91+
// Verify the cordoned node has the in-reboot-slot annotation.
92+
var bn bootcv1alpha1.BootcNode
93+
g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: cordonedNode}, &bn)).To(Succeed())
94+
g.Expect(bn.Annotations).To(HaveKey(bootcv1alpha1.AnnotationInRebootSlot))
95+
g.Expect(bn.Annotations).To(HaveKey(bootcv1alpha1.AnnotationWasCordoned))
96+
97+
// In envtest, drain completes instantly (no pods). Verify that
98+
// desiredImageState is set to Booted on the cordoned node.
99+
g.Eventually(func(g Gomega) {
100+
var bn bootcv1alpha1.BootcNode
101+
g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: cordonedNode}, &bn)).To(Succeed())
102+
g.Expect(bn.Spec.DesiredImageState).To(Equal(bootcv1alpha1.DesiredImageStateBooted))
103+
}).Should(Succeed())
104+
105+
// Verify the other nodes are NOT cordoned and still have
106+
// desiredImageState: Staged.
107+
for _, name := range nodeNames {
108+
if name == cordonedNode {
109+
continue
110+
}
111+
var node corev1.Node
112+
g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: name}, &node)).To(Succeed())
113+
g.Expect(node.Spec.Unschedulable).To(BeFalse(), "node %s should not be cordoned", name)
114+
115+
var bn bootcv1alpha1.BootcNode
116+
g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: name}, &bn)).To(Succeed())
117+
g.Expect(bn.Spec.DesiredImageState).To(Equal(bootcv1alpha1.DesiredImageStateStaged),
118+
"node %s should still have desiredImageState Staged", name)
119+
}
120+
}
121+
122+
// simulateDaemonStatus writes BootcNode status as if the daemon had
123+
// reported the given booted digest and Idle condition reason.
124+
func simulateDaemonStatus(g Gomega, ctx context.Context, nodeName, bootedDigest, idleReason string) {
125+
var bn bootcv1alpha1.BootcNode
126+
g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: nodeName}, &bn)).To(Succeed())
127+
128+
bn.Status.Booted = &bootcv1alpha1.ImageInfo{
129+
Image: "quay.io/example/myos@" + bootedDigest,
130+
ImageDigest: bootedDigest,
131+
}
132+
133+
idleStatus := metav1.ConditionFalse
134+
if idleReason == bootcv1alpha1.NodeReasonIdle {
135+
idleStatus = metav1.ConditionTrue
136+
}
137+
bn.Status.Conditions = []metav1.Condition{
138+
{
139+
Type: bootcv1alpha1.NodeIdle,
140+
Status: idleStatus,
141+
Reason: idleReason,
142+
LastTransitionTime: metav1.Now(),
143+
},
144+
}
145+
146+
g.Expect(k8sClient.Status().Update(ctx, &bn)).To(Succeed())
147+
}

internal/controller/suite_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ var (
2525
k8sClient client.Client
2626
)
2727

28+
// TODO: TestMain starts envtest and the manager unconditionally, which means
29+
// pure unit tests (e.g. TestClassifyNode) pay the startup cost even though
30+
// they don't need it. Consider lazy initialization (e.g. sync.Once) so the
31+
// environment is only started when a test actually uses k8sClient.
2832
func TestMain(m *testing.M) {
2933
ctrl.SetLogger(zap.New(zap.UseDevMode(true)))
3034

0 commit comments

Comments
 (0)