@@ -5,33 +5,174 @@ package controller
55import (
66 "context"
77 "fmt"
8+ "slices"
9+ "strings"
810
911 "github.com/distribution/reference"
12+ "github.com/go-logr/logr"
1013 apimeta "k8s.io/apimachinery/pkg/api/meta"
14+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
15+ "k8s.io/apimachinery/pkg/util/intstr"
1116 logf "sigs.k8s.io/controller-runtime/pkg/log"
1217
1318 bootcv1alpha1 "github.com/jlebon/bootc-operator/api/v1alpha1"
1419)
1520
16- // driveRollout iterates owned BootcNodes, classifies each by the state
17- // table, and logs their states. Transition logic is added in later
18- // commits.
21+ // rolloutState holds the classified BootcNodes for a single reconcile
22+ // pass.
23+ type rolloutState struct {
24+ // nodes are sorted into these buckets
25+ idle []* bootcv1alpha1.BootcNode
26+ staging []* bootcv1alpha1.BootcNode
27+ staged []* bootcv1alpha1.BootcNode
28+ rebooting []* bootcv1alpha1.BootcNode
29+ degraded []* bootcv1alpha1.BootcNode
30+ unclassified []* bootcv1alpha1.BootcNode
31+
32+ // BootcNodes with the in-reboot-slot annotation
33+ occupiedSlots int
34+ }
35+
36+ // nodeCount returns the total number of nodes in the pool, including
37+ // unclassified ones. Used for resolving percentage-based maxUnavailable.
38+ func (rs * rolloutState ) nodeCount () int {
39+ return len (rs .idle ) + len (rs .staging ) + len (rs .staged ) +
40+ len (rs .rebooting ) + len (rs .degraded ) + len (rs .unclassified )
41+ }
42+
43+ // driveRollout is the main function that advances the rollout state machine.
1944func (r * BootcNodePoolReconciler ) driveRollout (ctx context.Context , pool * bootcv1alpha1.BootcNodePool , ownedBootcNodes map [string ]* bootcv1alpha1.BootcNode ) error {
20- log := logf .FromContext (ctx ).WithValues ("pool" , pool .Name )
45+ log := logf .FromContext (ctx )
46+
47+ rs := buildRolloutState (log , ownedBootcNodes )
48+
49+ maxUnavail , err := resolveMaxUnavailable (pool , rs .nodeCount ())
50+ if err != nil {
51+ return err
52+ }
53+
54+ avail := max (0 , maxUnavail - rs .occupiedSlots )
55+ candidates := selectDrainCandidates (rs .staged , avail )
2156
57+ log .V (1 ).Info ("Rollout state" ,
58+ "idle" , len (rs .idle ),
59+ "staging" , len (rs .staging ),
60+ "staged" , len (rs .staged ),
61+ "rebooting" , len (rs .rebooting ),
62+ "degraded" , len (rs .degraded ),
63+ "unclassified" , nodeNames (rs .unclassified ),
64+ "occupiedSlots" , rs .occupiedSlots ,
65+ "maxUnavailable" , maxUnavail ,
66+ "availableSlots" , avail ,
67+ "candidates" , nodeNames (candidates ),
68+ )
69+
70+ return nil
71+ }
72+
73+ // buildRolloutState classifies all owned BootcNodes and counts occupied
74+ // reboot slots.
75+ func buildRolloutState (log logr.Logger , ownedBootcNodes map [string ]* bootcv1alpha1.BootcNode ) * rolloutState {
76+ rs := & rolloutState {}
2277 for _ , bn := range ownedBootcNodes {
78+ // Count occupied reboot slots from the persistent annotation.
79+ if metav1 .HasAnnotation (bn .ObjectMeta , bootcv1alpha1 .AnnotationInRebootSlot ) {
80+ rs .occupiedSlots ++
81+ }
82+
2383 state , err := classifyNode (bn )
2484 if err != nil {
25- // This can happen transiently (e.g. daemon hasn't populated
26- // booted status yet). Skip the node; it will be re-evaluated
27- // when the daemon updates the BootcNode.
28- log .V (1 ).Info ("Skipping unclassifiable node" , "node" , bn .Name , "error" , err )
85+ // as mentioned in classifyNode(), should never happen...
86+ log .Info ("WARNING: skipping unclassifiable node" , "node" , bn .Name , "error" , err )
87+ rs .unclassified = append (rs .unclassified , bn )
2988 continue
3089 }
3190 log .V (1 ).Info ("Classified node" , "node" , bn .Name , "state" , state .String ())
91+
92+ switch state {
93+ case nodeStateIdle :
94+ rs .idle = append (rs .idle , bn )
95+ case nodeStateStaging :
96+ rs .staging = append (rs .staging , bn )
97+ case nodeStateStaged :
98+ rs .staged = append (rs .staged , bn )
99+ case nodeStateRebooting :
100+ rs .rebooting = append (rs .rebooting , bn )
101+ case nodeStateDegraded :
102+ rs .degraded = append (rs .degraded , bn )
103+ }
104+ }
105+ return rs
106+ }
107+
108+ // resolveMaxUnavailable computes the effective maxUnavailable value from the
109+ // pool's rollout spec. Defaults to 1 when unset. A value of 0 is allowed and
110+ // means no reboot slots are available (effectively paused). Returns an
111+ // invalidSpecError if the value is malformed.
112+ func resolveMaxUnavailable (pool * bootcv1alpha1.BootcNodePool , nodeCount int ) (int , error ) {
113+ if pool .Spec .Rollout != nil && pool .Spec .Rollout .Paused {
114+ return 0 , nil
115+ }
116+ if pool .Spec .Rollout == nil || pool .Spec .Rollout .MaxUnavailable == nil {
117+ return 1 , nil
32118 }
33119
34- return nil
120+ // We roundUp here; this matches Deployments maxUnavailable for example
121+ v , err := intstr .GetScaledValueFromIntOrPercent (pool .Spec .Rollout .MaxUnavailable , nodeCount , true )
122+ if err != nil {
123+ return 0 , newInvalidSpecError (fmt .Sprintf ("invalid maxUnavailable %q: %v" , pool .Spec .Rollout .MaxUnavailable .String (), err ))
124+ }
125+ return v , nil
126+ }
127+
128+ // selectDrainCandidates picks Staged nodes that need the drain flow started or
129+ // restarted. Nodes that already have the in-reboot-slot annotation are always
130+ // included (e.g. they had a slot before a controller restart and need their
131+ // drain restarted). These nodes are already counted in occupiedSlots so they
132+ // don't consume availableSlots. Beyond those, up to availableSlots unslotted
133+ // nodes are appended, sorted alphabetically.
134+ func selectDrainCandidates (staged []* bootcv1alpha1.BootcNode , availableSlots int ) []* bootcv1alpha1.BootcNode {
135+ if len (staged ) == 0 {
136+ return nil
137+ }
138+
139+ // Partition into already-slotted vs new candidates.
140+ var slotted , unslotted []* bootcv1alpha1.BootcNode
141+ for _ , bn := range staged {
142+ if metav1 .HasAnnotation (bn .ObjectMeta , bootcv1alpha1 .AnnotationInRebootSlot ) {
143+ slotted = append (slotted , bn )
144+ } else {
145+ unslotted = append (unslotted , bn )
146+ }
147+ }
148+ slices .SortFunc (slotted , func (a , b * bootcv1alpha1.BootcNode ) int {
149+ return strings .Compare (a .Name , b .Name )
150+ })
151+ slices .SortFunc (unslotted , func (a , b * bootcv1alpha1.BootcNode ) int {
152+ return strings .Compare (a .Name , b .Name )
153+ })
154+
155+ // Always re-select slotted nodes. Fill remaining capacity with
156+ // unslotted nodes.
157+ result := slotted
158+ if availableSlots > 0 && len (unslotted ) > 0 {
159+ n := min (availableSlots , len (unslotted ))
160+ result = append (result , unslotted [:n ]... )
161+ }
162+
163+ if len (result ) == 0 {
164+ return nil
165+ }
166+ return result
167+ }
168+
169+ // nodeNames returns the names of the given BootcNodes for logging.
170+ func nodeNames (nodes []* bootcv1alpha1.BootcNode ) []string {
171+ names := make ([]string , len (nodes ))
172+ for i , n := range nodes {
173+ names [i ] = n .Name
174+ }
175+ return names
35176}
36177
37178// nodeState represents the effective state of a BootcNode as seen by
0 commit comments