Skip to content

Commit 650a1f2

Browse files
committed
Implement BootPolicy API handling in the ServerClaim
1 parent b4ecb99 commit 650a1f2

File tree

8 files changed

+210
-9
lines changed

8 files changed

+210
-9
lines changed

api/v1alpha1/serverclaim_types.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ import (
1212
// +kubebuilder:validation:XValidation:rule="!has(oldSelf.serverRef) || has(self.serverRef)", message="serverRef is required once set"
1313
// +kubebuilder:validation:XValidation:rule="!has(oldSelf.serverSelector) || has(self.serverSelector)", message="serverSelector is required once set"
1414
type ServerClaimSpec struct {
15+
// BootPolicy specifies how the server should be configured to boot from the network.
16+
// +kubebuilder:validation:Enum=None;NetworkBootOnce;NetworkBootAlways
17+
// +kubebuilder:default=NetworkBootOnce
18+
BootPolicy BootPolicy `json:"bootPolicy,omitempty"`
19+
1520
// Power specifies the desired power state of the server.
1621
// +required
1722
Power Power `json:"power"`
@@ -40,6 +45,18 @@ type ServerClaimSpec struct {
4045
Image string `json:"image"`
4146
}
4247

48+
// BootPolicy defines the boot behavior for a server claimed by a ServerClaim.
49+
type BootPolicy string
50+
51+
const (
52+
// BootPolicyNone indicates that no network boot should be configured when reconciling the server claim.
53+
BootPolicyNone BootPolicy = "None"
54+
// BootPolicyNetworkBootOnce configures the server to boot from the network once on the next power cycle.
55+
BootPolicyNetworkBootOnce BootPolicy = "NetworkBootOnce"
56+
// BootPolicyNetworkBootAlways configures the server to set PXE boot on every reconciliation.
57+
BootPolicyNetworkBootAlways BootPolicy = "NetworkBootAlways"
58+
)
59+
4360
// Phase defines the possible phases of a ServerClaim.
4461
type Phase string
4562

cmd/manager/main.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"fmt"
1111
"os"
1212
"path/filepath"
13+
"strings"
1314
"time"
1415

1516
"github.com/ironcore-dev/controller-utils/conditionutils"
@@ -92,6 +93,8 @@ func main() { // nolint: gocyclo
9293
serverMaxConcurrentReconciles int
9394
serverClaimMaxConcurrentReconciles int
9495
dnsRecordTemplatePath string
96+
firstBootConditionsRaw string
97+
firstBootConditions []string
9598
)
9699

97100
flag.IntVar(&serverMaxConcurrentReconciles, "server-max-concurrent-reconciles", 5,
@@ -150,6 +153,8 @@ func main() { // nolint: gocyclo
150153
"Timeout for BIOS Settings Controller")
151154
flag.StringVar(&dnsRecordTemplatePath, "dns-record-template-path", "",
152155
"Path to the DNS record template file used for creating DNS records for Servers.")
156+
flag.StringVar(&firstBootConditionsRaw, "first-boot-conditions", "IgnitionDataFetched,IPXEScriptFetched",
157+
"Comma-separated list of ServerBootConfiguration condition types that indicate first boot completion.")
153158

154159
opts := zap.Options{
155160
Development: true,
@@ -159,6 +164,8 @@ func main() { // nolint: gocyclo
159164

160165
ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))
161166

167+
firstBootConditions = splitAndTrimList(firstBootConditionsRaw)
168+
162169
if probeOSImage == "" {
163170
setupLog.Error(nil, "probe OS image must be set")
164171
os.Exit(1)
@@ -368,6 +375,7 @@ func main() { // nolint: gocyclo
368375
EnforcePowerOff: enforcePowerOff,
369376
MaxConcurrentReconciles: serverMaxConcurrentReconciles,
370377
Conditions: conditionutils.NewAccessor(conditionutils.AccessorOptions{}),
378+
FirstBootConditions: firstBootConditions,
371379
BMCOptions: bmc.Options{
372380
BasicAuth: true,
373381
PowerPollingInterval: powerPollingInterval,
@@ -584,3 +592,20 @@ func main() { // nolint: gocyclo
584592
os.Exit(1)
585593
}
586594
}
595+
596+
func splitAndTrimList(raw string) []string {
597+
if raw == "" {
598+
return nil
599+
}
600+
601+
parts := strings.Split(raw, ",")
602+
out := make([]string, 0, len(parts))
603+
for _, part := range parts {
604+
part = strings.TrimSpace(part)
605+
if part == "" {
606+
continue
607+
}
608+
out = append(out, part)
609+
}
610+
return out
611+
}

config/crd/bases/metal.ironcore.dev_serverclaims.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,15 @@ spec:
5555
spec:
5656
description: ServerClaimSpec defines the desired state of ServerClaim.
5757
properties:
58+
bootPolicy:
59+
default: NetworkBootOnce
60+
description: BootPolicy specifies how the server should be configured
61+
to boot from the network.
62+
enum:
63+
- None
64+
- NetworkBootOnce
65+
- NetworkBootAlways
66+
type: string
5867
ignitionSecretRef:
5968
description: |-
6069
IgnitionSecretRef is a reference to the Kubernetes Secret object that contains

dist/chart/templates/crd/metal.ironcore.dev_serverclaims.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,14 @@ spec:
8080
image:
8181
description: Image specifies the boot image to be used for the server.
8282
type: string
83+
bootPolicy:
84+
default: NetworkBootOnce
85+
description: BootPolicy specifies how the server should be configured to boot from the network.
86+
enum:
87+
- None
88+
- NetworkBootOnce
89+
- NetworkBootAlways
90+
type: string
8391
power:
8492
description: Power specifies the desired power state of the server.
8593
type: string

docs/api-reference/api.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -844,6 +844,24 @@ _Appears in:_
844844
| `device` _string_ | Device is the device to boot from. | | |
845845

846846

847+
#### BootPolicy
848+
849+
_Underlying type:_ _string_
850+
851+
BootPolicy defines the boot behavior for a server claimed by a ServerClaim.
852+
853+
854+
855+
_Appears in:_
856+
- [ServerClaimSpec](#serverclaimspec)
857+
858+
| Field | Description |
859+
| --- | --- |
860+
| `None` | BootPolicyNone indicates that no network boot should be configured when reconciling the server claim.<br /> |
861+
| `NetworkBootOnce` | BootPolicyNetworkBootOnce configures the server to boot from the network once on the next power cycle.<br /> |
862+
| `NetworkBootAlways` | BootPolicyNetworkBootAlways configures the server to set PXE boot on every reconciliation.<br /> |
863+
864+
847865
#### ConsoleProtocol
848866

849867

@@ -1326,6 +1344,7 @@ _Appears in:_
13261344

13271345
| Field | Description | Default | Validation |
13281346
| --- | --- | --- | --- |
1347+
| `bootPolicy` _[BootPolicy](#bootpolicy)_ | BootPolicy specifies how the server should be configured to boot from the network. | NetworkBootOnce | Enum: [None NetworkBootOnce NetworkBootAlways] <br /> |
13291348
| `power` _[Power](#power)_ | Power specifies the desired power state of the server. | | |
13301349
| `serverRef` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.34/#localobjectreference-v1-core)_ | ServerRef is a reference to a specific server to be claimed.<br />This field is optional and can be omitted if the server is to be selected using ServerSelector. | | Optional: \{\} <br /> |
13311350
| `serverSelector` _[LabelSelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.34/#labelselector-v1-meta)_ | ServerSelector specifies a label selector to identify the server to be claimed.<br />This field is optional and can be omitted if a specific server is referenced using ServerRef. | | Optional: \{\} <br /> |

docs/concepts/serverclaims.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ spec:
4343
name: my-ignition-secret
4444
```
4545
46+
## Boot Policies
47+
48+
- `bootPolicy: NetworkBootOnce` (default): The controller configures a one-time PXE boot when the server is restarted.
49+
- `bootPolicy: NetworkBootAlways`: The controller always refreshes the PXE boot configuration during reconciliation, ensuring every power cycle uses network boot.
50+
- `bootPolicy: None`: The controller does not configure PXE boot, preserving the existing boot configuration during power operations.
51+
4652
## Reconciliation Process
4753

4854
- [`ServerBootConfiguration`](serverbootconfigurations.md):

internal/controller/server_controller.go

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ type ServerReconciler struct {
9292
DiscoveryTimeout time.Duration
9393
MaxConcurrentReconciles int
9494
Conditions *conditionutils.Accessor
95+
FirstBootConditions []string
9596
}
9697

9798
//+kubebuilder:rbac:groups=metal.ironcore.dev,resources=bmcs,verbs=get;list;watch
@@ -320,7 +321,7 @@ func (r *ServerReconciler) handleInitialState(ctx context.Context, log logr.Logg
320321
}
321322

322323
func (r *ServerReconciler) handleDiscoveryState(ctx context.Context, log logr.Logger, bmcClient bmc.BMC, server *metalv1alpha1.Server) (bool, error) {
323-
if ready, err := r.serverBootConfigurationIsReady(ctx, server); err != nil || !ready {
324+
if _, ready, err := r.serverBootConfigurationIsReady(ctx, server); err != nil || !ready {
324325
log.V(1).Info("Server boot configuration is not ready. Retrying ...")
325326
return true, err
326327
}
@@ -412,15 +413,16 @@ func (r *ServerReconciler) handleReservedState(ctx context.Context, log logr.Log
412413
return true, err
413414
}
414415
}
415-
if ready, err := r.serverBootConfigurationIsReady(ctx, server); err != nil || !ready {
416+
bootConfig, ready, err := r.serverBootConfigurationIsReady(ctx, server)
417+
if err != nil || !ready {
416418
log.V(1).Info("Server boot configuration is not ready. Retrying ...")
417419
return true, err
418420
}
419421
log.V(1).Info("Server boot configuration is ready")
420422

421423
// TODO: fix properly, we need to free up the server if the claim does not exist anymore
422424
claim := &metalv1alpha1.ServerClaim{}
423-
err := r.Get(ctx, client.ObjectKey{
425+
err = r.Get(ctx, client.ObjectKey{
424426
Name: server.Spec.ServerClaimRef.Name,
425427
Namespace: server.Spec.ServerClaimRef.Namespace}, claim)
426428
if err != nil {
@@ -439,12 +441,13 @@ func (r *ServerReconciler) handleReservedState(ctx context.Context, log logr.Log
439441
return false, fmt.Errorf("failed to get ServerClaim: %w", err)
440442
}
441443

444+
shouldConfigureNetworkBoot := shouldPXEBootServer(claim, bootConfig, r.FirstBootConditions)
442445
//TODO: handle working Reserved Server that was suddenly powered off but needs to boot from disk
443-
if server.Status.PowerState == metalv1alpha1.ServerOffPowerState {
446+
if server.Status.PowerState == metalv1alpha1.ServerOffPowerState && shouldConfigureNetworkBoot {
444447
if err := r.pxeBootServer(ctx, log, bmcClient, server); err != nil {
445448
return false, fmt.Errorf("failed to boot server: %w", err)
446449
}
447-
log.V(1).Info("Server is powered off, booting Server in PXE")
450+
log.V(1).Info("Server is powered off and boot policy requires PXE, booting Server in PXE", "BootPolicy", claim.Spec.BootPolicy)
448451
}
449452
if err := r.ensureServerPowerState(ctx, log, bmcClient, server); err != nil {
450453
return false, fmt.Errorf("failed to ensure server power state: %w", err)
@@ -764,15 +767,15 @@ func (r *ServerReconciler) setAndPatchServerPowerState(ctx context.Context, log
764767
return false, nil
765768
}
766769

767-
func (r *ServerReconciler) serverBootConfigurationIsReady(ctx context.Context, server *metalv1alpha1.Server) (bool, error) {
770+
func (r *ServerReconciler) serverBootConfigurationIsReady(ctx context.Context, server *metalv1alpha1.Server) (*metalv1alpha1.ServerBootConfiguration, bool, error) {
768771
if server.Spec.BootConfigurationRef == nil {
769-
return false, nil
772+
return nil, false, nil
770773
}
771774
config := &metalv1alpha1.ServerBootConfiguration{}
772775
if err := r.Get(ctx, client.ObjectKey{Namespace: server.Spec.BootConfigurationRef.Namespace, Name: server.Spec.BootConfigurationRef.Name}, config); err != nil {
773-
return false, err
776+
return nil, false, err
774777
}
775-
return config.Status.State == metalv1alpha1.ServerBootConfigurationStateReady, nil
778+
return config, config.Status.State == metalv1alpha1.ServerBootConfigurationStateReady, nil
776779
}
777780

778781
func (r *ServerReconciler) pxeBootServer(ctx context.Context, log logr.Logger, bmcClient bmc.BMC, server *metalv1alpha1.Server) error {
@@ -791,6 +794,49 @@ func (r *ServerReconciler) pxeBootServer(ctx context.Context, log logr.Logger, b
791794
return nil
792795
}
793796

797+
func shouldPXEBootServer(claim *metalv1alpha1.ServerClaim, bootConfig *metalv1alpha1.ServerBootConfiguration, firstBootConditions []string) bool {
798+
if claim == nil {
799+
return false
800+
}
801+
802+
switch claim.Spec.BootPolicy {
803+
case "", metalv1alpha1.BootPolicyNetworkBootOnce:
804+
return !bootConfigHasFirstBootCondition(bootConfig, firstBootConditions)
805+
case metalv1alpha1.BootPolicyNetworkBootAlways:
806+
return true
807+
default:
808+
return false
809+
}
810+
}
811+
812+
func bootConfigHasFirstBootCondition(bootConfig *metalv1alpha1.ServerBootConfiguration, firstBootConditions []string) bool {
813+
if bootConfig == nil || len(firstBootConditions) == 0 {
814+
return false
815+
}
816+
817+
conditionSet := make(map[string]struct{}, len(firstBootConditions))
818+
for _, conditionType := range firstBootConditions {
819+
conditionType = strings.TrimSpace(conditionType)
820+
if conditionType == "" {
821+
continue
822+
}
823+
conditionSet[conditionType] = struct{}{}
824+
}
825+
if len(conditionSet) == 0 {
826+
return false
827+
}
828+
829+
for _, condition := range bootConfig.Status.Conditions {
830+
if condition.Status != metav1.ConditionTrue {
831+
continue
832+
}
833+
if _, ok := conditionSet[condition.Type]; ok {
834+
return true
835+
}
836+
}
837+
return false
838+
}
839+
794840
func (r *ServerReconciler) extractServerDetailsFromRegistry(ctx context.Context, log logr.Logger, server *metalv1alpha1.Server) (bool, error) {
795841
resp, err := http.Get(fmt.Sprintf("%s/systems/%s", r.RegistryURL, server.Spec.SystemUUID))
796842
if resp != nil && resp.StatusCode == http.StatusNotFound {

internal/controller/server_controller_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -795,6 +795,77 @@ var _ = Describe("Server Controller", func() {
795795
})
796796
})
797797

798+
var _ = Describe("shouldPXEBootServer", func() {
799+
var claim *metalv1alpha1.ServerClaim
800+
var bootConfig *metalv1alpha1.ServerBootConfiguration
801+
var firstBootConditions []string
802+
803+
BeforeEach(func() {
804+
claim = &metalv1alpha1.ServerClaim{
805+
Spec: metalv1alpha1.ServerClaimSpec{
806+
BootPolicy: metalv1alpha1.BootPolicyNetworkBootOnce,
807+
},
808+
}
809+
bootConfig = &metalv1alpha1.ServerBootConfiguration{}
810+
firstBootConditions = []string{"IgnitionDataFetched", "IPXEScriptFetched"}
811+
})
812+
813+
It("returns true for NetworkBootAlways regardless of boot config conditions", func() {
814+
claim.Spec.BootPolicy = metalv1alpha1.BootPolicyNetworkBootAlways
815+
Expect(shouldPXEBootServer(claim, bootConfig, firstBootConditions)).To(BeTrue())
816+
})
817+
818+
It("returns false for NetworkBootOnce when a first-boot condition is true", func() {
819+
bootConfig.Status.Conditions = []metav1.Condition{
820+
{
821+
Type: "IPXEScriptFetched",
822+
Status: metav1.ConditionTrue,
823+
},
824+
}
825+
Expect(shouldPXEBootServer(claim, bootConfig, firstBootConditions)).To(BeFalse())
826+
})
827+
828+
It("treats an empty BootPolicy as NetworkBootOnce", func() {
829+
claim.Spec.BootPolicy = ""
830+
bootConfig.Status.Conditions = []metav1.Condition{
831+
{
832+
Type: "IgnitionDataFetched",
833+
Status: metav1.ConditionTrue,
834+
},
835+
}
836+
Expect(shouldPXEBootServer(claim, bootConfig, firstBootConditions)).To(BeFalse())
837+
})
838+
839+
It("returns true for NetworkBootOnce when no first-boot conditions match", func() {
840+
bootConfig.Status.Conditions = []metav1.Condition{
841+
{
842+
Type: "FooBarFetched",
843+
Status: metav1.ConditionTrue,
844+
},
845+
}
846+
Expect(shouldPXEBootServer(claim, bootConfig, firstBootConditions)).To(BeTrue())
847+
})
848+
849+
It("returns true for NetworkBootOnce when first-boot conditions are not true", func() {
850+
bootConfig.Status.Conditions = []metav1.Condition{
851+
{
852+
Type: "IPXEScriptFetched",
853+
Status: metav1.ConditionFalse,
854+
},
855+
}
856+
Expect(shouldPXEBootServer(claim, bootConfig, firstBootConditions)).To(BeTrue())
857+
})
858+
859+
It("returns true for NetworkBootOnce when no boot config is provided", func() {
860+
Expect(shouldPXEBootServer(claim, nil, firstBootConditions)).To(BeTrue())
861+
})
862+
863+
It("returns false for BootPolicyNone", func() {
864+
claim.Spec.BootPolicy = metalv1alpha1.BootPolicyNone
865+
Expect(shouldPXEBootServer(claim, bootConfig, firstBootConditions)).To(BeFalse())
866+
})
867+
})
868+
798869
func deleteRegistrySystemIfExists(systemUUID string) {
799870
response, err := http.Get(registryURL + "/systems/" + systemUUID)
800871
if err != nil {

0 commit comments

Comments
 (0)