diff --git a/api/v1alpha1/ipxebootconfig_types.go b/api/v1alpha1/ipxebootconfig_types.go
index f0508c6f..bb406576 100644
--- a/api/v1alpha1/ipxebootconfig_types.go
+++ b/api/v1alpha1/ipxebootconfig_types.go
@@ -24,10 +24,14 @@ type IPXEBootConfigSpec struct {
Image string `json:"image,omitempty"`
// KernelURL is the URL where the kernel of the OS is hosted, eg. the URL to the Kernel layer of the OS OCI image.
- KernelURL string `json:"kernelURL,omitempty"`
+ // +kubebuilder:validation:Required
+ // +kubebuilder:validation:MinLength=1
+ KernelURL string `json:"kernelURL"`
// InitrdURL is the URL where the Initrd (initial RAM disk) of the OS is hosted, eg. the URL to the Initrd layer of the OS OCI image.
- InitrdURL string `json:"initrdURL,omitempty"`
+ // +kubebuilder:validation:Required
+ // +kubebuilder:validation:MinLength=1
+ InitrdURL string `json:"initrdURL"`
// SquashfsURL is the URL where the Squashfs of the OS is hosted, eg. the URL to the Squashfs layer of the OS OCI image.
SquashfsURL string `json:"squashfsURL,omitempty"`
diff --git a/cmdutils/move_test.go b/cmdutils/move_test.go
index 2aa81695..b675032e 100644
--- a/cmdutils/move_test.go
+++ b/cmdutils/move_test.go
@@ -62,6 +62,8 @@ var _ = Describe("bootctl move", func() {
sourceIPXESecret := create(ctx, clients.Source, namedObj(&corev1.Secret{}, "ipxe-secret"))
sourceIPXEBootConfig := namedObj(&bootv1alphav1.IPXEBootConfig{}, "test-ipxe-boot-config")
+ sourceIPXEBootConfig.Spec.KernelURL = "http://example.com/kernel"
+ sourceIPXEBootConfig.Spec.InitrdURL = "http://example.com/initrd"
sourceIPXEBootConfig.Spec.IgnitionSecretRef = &corev1.LocalObjectReference{Name: sourceIPXESecret.Name}
sourceIPXEBootConfig = create(ctx, clients.Source, sourceIPXEBootConfig)
diff --git a/config/crd/bases/boot.ironcore.dev_ipxebootconfigs.yaml b/config/crd/bases/boot.ironcore.dev_ipxebootconfigs.yaml
index a4246dc7..1b02ff28 100644
--- a/config/crd/bases/boot.ironcore.dev_ipxebootconfigs.yaml
+++ b/config/crd/bases/boot.ironcore.dev_ipxebootconfigs.yaml
@@ -68,6 +68,7 @@ spec:
description: InitrdURL is the URL where the Initrd (initial RAM disk)
of the OS is hosted, eg. the URL to the Initrd layer of the OS OCI
image.
+ minLength: 1
type: string
ipxeScriptSecretRef:
description: IPXEScriptSecretRef is a reference to the secret containing
@@ -90,6 +91,7 @@ spec:
kernelURL:
description: KernelURL is the URL where the kernel of the OS is hosted,
eg. the URL to the Kernel layer of the OS OCI image.
+ minLength: 1
type: string
squashfsURL:
description: SquashfsURL is the URL where the Squashfs of the OS is
@@ -103,6 +105,9 @@ spec:
systemUUID:
description: SystemUUID is the unique identifier (UUID) of the server.
type: string
+ required:
+ - initrdURL
+ - kernelURL
type: object
status:
description: IPXEBootConfigStatus defines the observed state of IPXEBootConfig
diff --git a/dist/chart/templates/crd/boot.ironcore.dev_ipxebootconfigs.yaml b/dist/chart/templates/crd/boot.ironcore.dev_ipxebootconfigs.yaml
index cef94a97..bb781ba6 100755
--- a/dist/chart/templates/crd/boot.ironcore.dev_ipxebootconfigs.yaml
+++ b/dist/chart/templates/crd/boot.ironcore.dev_ipxebootconfigs.yaml
@@ -74,6 +74,7 @@ spec:
description: InitrdURL is the URL where the Initrd (initial RAM disk)
of the OS is hosted, eg. the URL to the Initrd layer of the OS OCI
image.
+ minLength: 1
type: string
ipxeScriptSecretRef:
description: IPXEScriptSecretRef is a reference to the secret containing
@@ -96,6 +97,7 @@ spec:
kernelURL:
description: KernelURL is the URL where the kernel of the OS is hosted,
eg. the URL to the Kernel layer of the OS OCI image.
+ minLength: 1
type: string
squashfsURL:
description: SquashfsURL is the URL where the Squashfs of the OS is
@@ -109,6 +111,9 @@ spec:
systemUUID:
description: SystemUUID is the unique identifier (UUID) of the server.
type: string
+ required:
+ - initrdURL
+ - kernelURL
type: object
status:
description: IPXEBootConfigStatus defines the observed state of IPXEBootConfig
diff --git a/docs/api-reference/api.md b/docs/api-reference/api.md
index 13bf47ad..e71d2e22 100644
--- a/docs/api-reference/api.md
+++ b/docs/api-reference/api.md
@@ -124,8 +124,8 @@ _Appears in:_
| `systemUUID` _string_ | SystemUUID is the unique identifier (UUID) of the server. | | |
| `systemIPs` _string array_ | SystemIPs is a list of IP addresses assigned to the server. | | |
| `image` _string_ | Image is deprecated and will be removed. | | |
-| `kernelURL` _string_ | KernelURL is the URL where the kernel of the OS is hosted, eg. the URL to the Kernel layer of the OS OCI image. | | |
-| `initrdURL` _string_ | InitrdURL is the URL where the Initrd (initial RAM disk) of the OS is hosted, eg. the URL to the Initrd layer of the OS OCI image. | | |
+| `kernelURL` _string_ | KernelURL is the URL where the kernel of the OS is hosted, eg. the URL to the Kernel layer of the OS OCI image. | | MinLength: 1
Required: \{\}
|
+| `initrdURL` _string_ | InitrdURL is the URL where the Initrd (initial RAM disk) of the OS is hosted, eg. the URL to the Initrd layer of the OS OCI image. | | MinLength: 1
Required: \{\}
|
| `squashfsURL` _string_ | SquashfsURL is the URL where the Squashfs of the OS is hosted, eg. the URL to the Squashfs layer of the OS OCI image. | | |
| `ipxeServerURL` _string_ | IPXEServerURL is deprecated and will be removed. | | |
| `ignitionSecretRef` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#localobjectreference-v1-core)_ | IgnitionSecretRef is a reference to the secret containing the Ignition configuration. | | |
diff --git a/internal/controller/ipxebootconfig_controller_test.go b/internal/controller/ipxebootconfig_controller_test.go
index 88cd4006..501c4702 100644
--- a/internal/controller/ipxebootconfig_controller_test.go
+++ b/internal/controller/ipxebootconfig_controller_test.go
@@ -38,7 +38,10 @@ var _ = Describe("IPXEBootConfig Controller", func() {
Name: resourceName,
Namespace: "default",
},
- // TODO(user): Specify other spec details if needed.
+ Spec: bootv1alpha1.IPXEBootConfigSpec{
+ KernelURL: "http://example.com/kernel",
+ InitrdURL: "http://example.com/initrd",
+ },
}
Expect(k8sClient.Create(ctx, resource)).To(Succeed())
}
diff --git a/internal/controller/serverbootconfiguration_pxe_controller.go b/internal/controller/serverbootconfiguration_pxe_controller.go
index 32db81b3..184727de 100644
--- a/internal/controller/serverbootconfiguration_pxe_controller.go
+++ b/internal/controller/serverbootconfiguration_pxe_controller.go
@@ -212,9 +212,16 @@ func (r *ServerBootConfigurationPXEReconciler) getImageDetailsFromConfig(ctx con
return "", "", "", fmt.Errorf("failed to fetch layer digests: %w", err)
}
- kernelURL := buildImageURL(r.IPXEServiceURL, imageName, imageVersion, kernelDigest)
- initrdURL := buildImageURL(r.IPXEServiceURL, imageName, imageVersion, initrdDigest)
- squashFSURL := buildImageURL(r.IPXEServiceURL, imageName, imageVersion, squashFSDigest)
+ var kernelURL, initrdURL, squashFSURL string
+ if kernelDigest != "" {
+ kernelURL = buildImageURL(r.IPXEServiceURL, imageName, imageVersion, kernelDigest)
+ }
+ if initrdDigest != "" {
+ initrdURL = buildImageURL(r.IPXEServiceURL, imageName, imageVersion, initrdDigest)
+ }
+ if squashFSDigest != "" {
+ squashFSURL = buildImageURL(r.IPXEServiceURL, imageName, imageVersion, squashFSDigest)
+ }
log.V(1).Info("Built image URLs", "kernelURL", kernelURL, "initrdURL", initrdURL, "squashfsURL", squashFSURL)
return kernelURL, initrdURL, squashFSURL, nil
@@ -258,10 +265,6 @@ func (r *ServerBootConfigurationPXEReconciler) getLayerDigestsFromNestedManifest
}
}
- if kernelDigest == "" || initrdDigest == "" || squashFSDigest == "" {
- return "", "", "", fmt.Errorf("failed to find all required layer digests")
- }
-
return kernelDigest, initrdDigest, squashFSDigest, nil
}
diff --git a/internal/controller/serverbootconfiguration_pxe_controller_test.go b/internal/controller/serverbootconfiguration_pxe_controller_test.go
index 59daf307..3a8044db 100644
--- a/internal/controller/serverbootconfiguration_pxe_controller_test.go
+++ b/internal/controller/serverbootconfiguration_pxe_controller_test.go
@@ -152,4 +152,59 @@ var _ = Describe("ServerBootConfiguration Controller", func() {
HaveField("Spec.IgnitionSecretRef.Name", "foo"),
))
})
+
+ It("should map a ServerBootConfiguration using an initramfs-only oci image (no squashfs)", func(ctx SpecContext) {
+ By("creating a new Server object")
+ server := &metalv1alpha1.Server{
+ ObjectMeta: metav1.ObjectMeta{
+ GenerateName: "server-",
+ },
+ Spec: metalv1alpha1.ServerSpec{
+ SystemUUID: "12345",
+ },
+ }
+ Expect(k8sClient.Create(ctx, server)).To(Succeed())
+
+ By("patching the Server NICs in Server status")
+ Eventually(UpdateStatus(server, func() {
+ server.Status.NetworkInterfaces = []metalv1alpha1.NetworkInterface{
+ {
+ Name: "foo",
+ IPs: []metalv1alpha1.IP{metalv1alpha1.MustParseIP("1.1.1.1")},
+ MACAddress: "abcd",
+ },
+ }
+ })).Should(Succeed())
+
+ By("creating a new ServerBootConfiguration")
+ config := &metalv1alpha1.ServerBootConfiguration{
+ ObjectMeta: metav1.ObjectMeta{
+ Namespace: ns.Name,
+ GenerateName: "test-",
+ },
+ Spec: metalv1alpha1.ServerBootConfigurationSpec{
+ ServerRef: corev1.LocalObjectReference{
+ Name: server.Name,
+ },
+ Image: MockImageRef("ironcore-dev/os-images/sanitizer", "1.0.0"),
+ IgnitionSecretRef: &corev1.LocalObjectReference{Name: "foo"},
+ },
+ }
+ Expect(k8sClient.Create(ctx, config)).To(Succeed())
+
+ By("ensuring that the ipxe boot configuration has no squashfs URL")
+ bootConfig := &v1alpha1.IPXEBootConfig{
+ ObjectMeta: metav1.ObjectMeta{
+ Namespace: ns.Name,
+ Name: config.Name,
+ },
+ }
+ Eventually(Object(bootConfig)).Should(SatisfyAll(
+ HaveField("Spec.SystemUUID", server.Spec.SystemUUID),
+ HaveField("Spec.SystemIPs", ContainElement("1.1.1.1")),
+ HaveField("Spec.KernelURL", Not(BeEmpty())),
+ HaveField("Spec.InitrdURL", Not(BeEmpty())),
+ HaveField("Spec.SquashfsURL", BeEmpty()),
+ ))
+ })
})
diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go
index e89ba00c..27e9b9b3 100644
--- a/internal/controller/suite_test.go
+++ b/internal/controller/suite_test.go
@@ -73,6 +73,7 @@ var _ = BeforeSuite(func() {
// Push test images to mock registry (using simple paths without localhost prefix)
Expect(mockRegistry.PushPXEImage("ironcore-dev/os-images/gardenlinux", "1877.0", runtime.GOARCH)).To(Succeed())
Expect(mockRegistry.PushPXEImageOldFormat("gardenlinux/gardenlinux", "1772.0", runtime.GOARCH)).To(Succeed())
+ Expect(mockRegistry.PushPXEImageNoSquashFS("ironcore-dev/os-images/sanitizer", "1.0.0", runtime.GOARCH)).To(Succeed())
Expect(mockRegistry.PushHTTPImage("ironcore-dev/os-images/test-image", "100.1")).To(Succeed())
// Set allowed registries to use mock registry
diff --git a/server/bootserver_test.go b/server/bootserver_test.go
index f135b1cf..f689c6c8 100644
--- a/server/bootserver_test.go
+++ b/server/bootserver_test.go
@@ -4,9 +4,12 @@
package server
import (
+ "bytes"
"context"
"encoding/json"
"net/http"
+ "strings"
+ "text/template"
"github.com/go-logr/logr"
bootv1alpha1 "github.com/ironcore-dev/boot-operator/api/v1alpha1"
@@ -406,6 +409,41 @@ var _ = Describe("ConfigSelector", func() {
})
})
+ Context("iPXE script template", func() {
+ renderIPXEScript := func(data IPXETemplateData) string {
+ tmpl, err := template.ParseFiles("../templates/ipxe-script.tpl")
+ Expect(err).NotTo(HaveOccurred())
+ var buf bytes.Buffer
+ Expect(tmpl.Execute(&buf, data)).To(Succeed())
+ return buf.String()
+ }
+
+ It("includes squashfs kernel params when SquashfsURL is set", func() {
+ script := renderIPXEScript(IPXETemplateData{
+ KernelURL: "http://example.com/kernel",
+ InitrdURL: "http://example.com/initrd",
+ SquashfsURL: "http://example.com/squashfs",
+ IPXEServerURL: "http://example.com",
+ })
+ Expect(script).To(ContainSubstring("set squashfs-url http://example.com/squashfs"))
+ Expect(script).To(ContainSubstring("gl.ovl=/:tmpfs"))
+ Expect(script).To(ContainSubstring("gl.url=${squashfs-url}"))
+ Expect(script).To(ContainSubstring("gl.live=1"))
+ })
+
+ It("omits squashfs kernel params when SquashfsURL is empty", func() {
+ script := renderIPXEScript(IPXETemplateData{
+ KernelURL: "http://example.com/kernel",
+ InitrdURL: "http://example.com/initrd",
+ SquashfsURL: "",
+ IPXEServerURL: "http://example.com",
+ })
+ Expect(strings.Contains(script, "squashfs-url")).To(BeFalse())
+ Expect(strings.Contains(script, "gl.ovl")).To(BeFalse())
+ Expect(strings.Contains(script, "gl.live")).To(BeFalse())
+ })
+ })
+
Context("resolveServer", func() {
It("skips deleted SBCs and resolves via the next one", func() {
server := &metalv1alpha1.Server{
diff --git a/templates/ipxe-script.tpl b/templates/ipxe-script.tpl
index ba88d8e7..333c7023 100644
--- a/templates/ipxe-script.tpl
+++ b/templates/ipxe-script.tpl
@@ -3,11 +3,11 @@
set ipxe-svc {{.IPXEServerURL}}
set kernel-url {{.KernelURL}}
set initrd-url {{.InitrdURL}}
-set squashfs-url {{.SquashfsURL}}
-
+{{if .SquashfsURL}}set squashfs-url {{.SquashfsURL}}
+{{end}}
echo Loading kernel...
-kernel ${kernel-url} initrd=initrd gl.ovl=/:tmpfs gl.url=${squashfs-url} gl.live=1 ip=any ignition.firstboot=1 ignition.config.url=${ipxe-svc}/ignition/${uuid} ignition.platform.id=metal console=ttyS0,115200 console=tty0 console=ttyAMA0 earlyprintk=ttyS0,115200 consoleblank=0
+kernel ${kernel-url} initrd=initrd{{if .SquashfsURL}} gl.ovl=/:tmpfs gl.url=${squashfs-url} gl.live=1{{end}} ip=any ignition.firstboot=1 ignition.config.url=${ipxe-svc}/ignition/${uuid} ignition.platform.id=metal console=ttyS0,115200 console=tty0 console=ttyAMA0 earlyprintk=ttyS0,115200 consoleblank=0
echo Loading initrd...
initrd ${initrd-url}
echo Booting...
-boot
\ No newline at end of file
+boot
diff --git a/test/registry/registry.go b/test/registry/registry.go
index 68c4e7d4..f2d84fea 100644
--- a/test/registry/registry.go
+++ b/test/registry/registry.go
@@ -29,6 +29,8 @@ const (
MediaTypeKernelOld = "application/io.gardenlinux.kernel"
// MediaTypeInitrdOld represents the legacy initrd media type
MediaTypeInitrdOld = "application/io.gardenlinux.initrd"
+ // MediaTypeOCIConfig represents the OCI image config media type
+ MediaTypeOCIConfig = "application/vnd.oci.image.config.v1+json"
)
// MockRegistry provides an in-memory OCI registry for testing
@@ -100,7 +102,7 @@ func (r *MockRegistry) pushPXEManifest(name, tag string, kernelMedia, initrdMedi
manifest := ocispec.Manifest{
MediaType: ocispec.MediaTypeImageManifest,
Config: ocispec.Descriptor{
- MediaType: "application/vnd.oci.image.config.v1+json",
+ MediaType: MediaTypeOCIConfig,
Digest: configDigest,
Size: 2,
},
@@ -137,6 +139,45 @@ func (r *MockRegistry) pushPXEManifest(name, tag string, kernelMedia, initrdMedi
r.blobs[squashfsDigest] = []byte("squashfs-data")
}
+// pushPXEManifestNoSquashFS stores a PXE manifest with only kernel and initrd layers.
+func (r *MockRegistry) pushPXEManifestNoSquashFS(name, tag string, kernelMedia, initrdMedia string) {
+ kernelDigest := digest.FromString(fmt.Sprintf("kernel-%s-%s", name, tag))
+ initrdDigest := digest.FromString(fmt.Sprintf("initrd-%s-%s", name, tag))
+ configDigest := digest.FromString(fmt.Sprintf("config-%s-%s", name, tag))
+
+ manifest := ocispec.Manifest{
+ MediaType: ocispec.MediaTypeImageManifest,
+ Config: ocispec.Descriptor{
+ MediaType: MediaTypeOCIConfig,
+ Digest: configDigest,
+ Size: 2,
+ },
+ Layers: []ocispec.Descriptor{
+ {
+ MediaType: kernelMedia,
+ Digest: kernelDigest,
+ Size: 1024,
+ },
+ {
+ MediaType: initrdMedia,
+ Digest: initrdDigest,
+ Size: 2048,
+ },
+ },
+ }
+
+ ref := fmt.Sprintf("%s:%s", name, tag)
+ r.manifests[ref] = manifest
+
+ manifestBytes, _ := json.Marshal(manifest)
+ manifestDigest := digest.FromBytes(manifestBytes)
+ r.manifestsByDigest[manifestDigest] = manifest
+
+ r.blobs[manifest.Config.Digest] = []byte("{}")
+ r.blobs[kernelDigest] = []byte("kernel-data")
+ r.blobs[initrdDigest] = []byte("initrd-data")
+}
+
// PushPXEImage adds a PXE boot image with kernel, initrd, and squashfs layers
func (r *MockRegistry) PushPXEImage(name, tag, architecture string) error {
r.mu.Lock()
@@ -145,6 +186,15 @@ func (r *MockRegistry) PushPXEImage(name, tag, architecture string) error {
return nil
}
+// PushPXEImageNoSquashFS adds a PXE boot image with only kernel and initrd layers (no squashfs).
+// Used for initramfs-only images such as the sanitizer.
+func (r *MockRegistry) PushPXEImageNoSquashFS(name, tag, architecture string) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.pushPXEManifestNoSquashFS(name, tag, MediaTypeKernel, MediaTypeInitrd)
+ return nil
+}
+
// PushPXEImageOldFormat adds a PXE boot image using old Gardenlinux media types
func (r *MockRegistry) PushPXEImageOldFormat(name, tag, architecture string) error {
r.mu.Lock()
@@ -164,7 +214,7 @@ func (r *MockRegistry) PushHTTPImage(name, tag string) error {
manifest := ocispec.Manifest{
MediaType: ocispec.MediaTypeImageManifest,
Config: ocispec.Descriptor{
- MediaType: "application/vnd.oci.image.config.v1+json",
+ MediaType: MediaTypeOCIConfig,
Digest: configDigest,
Size: 2,
},