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, },