From 6128937c4a9dcb581ac0e5f0d30888c66c565989 Mon Sep 17 00:00:00 2001 From: Hardik Dodiya Date: Thu, 19 Feb 2026 13:22:09 +0100 Subject: [PATCH 1/2] Add OCI-based default UKI Configuration for HTTPBoot --- Dockerfile | 1 + cmd/main.go | 31 +++++-- internal/uki/oci.go | 163 +++++++++++++++++++++++++++++++++ internal/uki/oci_test.go | 123 +++++++++++++++++++++++++ server/bootserver.go | 63 ++++++++++++- server/bootserver_suit_test.go | 13 ++- 6 files changed, 380 insertions(+), 14 deletions(-) create mode 100644 internal/uki/oci.go create mode 100644 internal/uki/oci_test.go diff --git a/Dockerfile b/Dockerfile index 7338d43..95d10a1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,7 @@ COPY cmd/main.go cmd/main.go COPY api/ api/ COPY internal/controller/ internal/controller/ COPY internal/registry/ internal/registry/ +COPY internal/uki/ internal/uki/ COPY server/ server/ COPY templates/ templates/ diff --git a/cmd/main.go b/cmd/main.go index ab05ab0..cd0e132 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -64,7 +64,6 @@ func init() { func main() { ctx := ctrl.LoggerInto(ctrl.SetupSignalHandler(), setupLog) - defaultHttpUKIURL := NewDefaultHTTPBootData() skipControllerNameValidation := true var metricsAddr string @@ -81,12 +80,16 @@ func main() { var imageServerURL string var architecture string var allowedRegistries string + var defaultHTTPBootOCIImage string + var defaultHTTPBootUKIURL string flag.StringVar(&architecture, "architecture", "amd64", "Target system architecture (e.g., amd64, arm64)") flag.IntVar(&ipxeServicePort, "ipxe-service-port", 5000, "IPXE Service port to listen on.") flag.StringVar(&ipxeServiceProtocol, "ipxe-service-protocol", "http", "IPXE Service Protocol.") flag.StringVar(&ipxeServiceURL, "ipxe-service-url", "", "IPXE Service URL.") flag.StringVar(&imageServerURL, "image-server-url", "", "OS Image Server URL.") + flag.StringVar(&defaultHTTPBootOCIImage, "default-httpboot-oci-image", "", "Default OCI image reference for http boot") + flag.StringVar(&defaultHTTPBootUKIURL, "default-httpboot-uki-url", "", "Deprecated: use --default-httpboot-oci-image") flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") flag.StringVar(&bootserverAddr, "boot-server-address", ":8082", "The address the boot-server binds to.") @@ -125,6 +128,13 @@ func main() { ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + if defaultHTTPBootUKIURL != "" { + setupLog.Info("Flag --default-httpboot-uki-url is deprecated; use --default-httpboot-oci-image instead") + } + if defaultHTTPBootOCIImage != "" && defaultHTTPBootUKIURL != "" { + setupLog.Info("Ignoring --default-httpboot-uki-url because --default-httpboot-oci-image is set") + } + // set the correct ipxe service URL by getting the address from the environment var ipxeServiceAddr string if ipxeServiceURL == "" { @@ -317,7 +327,17 @@ func main() { setupLog.Info("starting boot-server") go func() { - if err := bootserver.RunBootServer(bootserverAddr, ipxeServiceURL, mgr.GetClient(), serverLog.WithName("bootserver"), *defaultHttpUKIURL); err != nil { + if err := bootserver.RunBootServer( + bootserverAddr, + ipxeServiceURL, + mgr.GetClient(), + serverLog.WithName("bootserver"), + registryValidator, + defaultHTTPBootOCIImage, + defaultHTTPBootUKIURL, + imageServerURL, + architecture, + ); err != nil { setupLog.Error(err, "boot-server exited") panic(err) } @@ -379,10 +399,3 @@ func IndexHTTPBootConfigByNetworkIDs(ctx context.Context, mgr ctrl.Manager) erro }, ) } - -func NewDefaultHTTPBootData() *string { - var defaultUKIURL string - flag.StringVar(&defaultUKIURL, "default-httpboot-uki-url", "", "Default UKI URL for http boot") - - return &defaultUKIURL -} diff --git a/internal/uki/oci.go b/internal/uki/oci.go new file mode 100644 index 0000000..cd13138 --- /dev/null +++ b/internal/uki/oci.go @@ -0,0 +1,163 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package uki + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/containerd/containerd/remotes" + "github.com/containerd/containerd/remotes/docker" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +const MediaTypeUKI = "application/vnd.ironcore.image.uki" + +func ConstructUKIURLFromOCI(ctx context.Context, image string, imageServerURL string, architecture string) (string, error) { + repository, imageRef, err := parseOCIReferenceForUKI(image) + if err != nil { + return "", err + } + + ukiDigest, err := getUKIDigestFromNestedManifest(ctx, imageRef, architecture) + if err != nil { + return "", fmt.Errorf("failed to fetch UKI layer digest: %w", err) + } + + ukiDigest = strings.TrimPrefix(ukiDigest, "sha256:") + ukiURL := fmt.Sprintf("%s/%s/sha256-%s.efi", imageServerURL, repository, ukiDigest) + return ukiURL, nil +} + +func parseOCIReferenceForUKI(image string) (repository string, imageRef string, err error) { + // Split digest first. Note: digest values contain ':' (e.g., sha256:...), so we must not split on ':'. + base, digest, hasDigest := strings.Cut(image, "@") + if hasDigest { + if base == "" || digest == "" { + return "", "", fmt.Errorf("invalid OCI image reference %q", image) + } + + repository = base + if tagSep := lastTagSeparatorIndex(base); tagSep >= 0 { + repository = base[:tagSep] + } + if repository == "" { + return "", "", fmt.Errorf("invalid OCI image reference %q", image) + } + + return repository, base + "@" + digest, nil + } + + tagSep := lastTagSeparatorIndex(image) + if tagSep < 0 { + return "", "", fmt.Errorf("invalid OCI image reference %q: expected name:tag or name@digest", image) + } + + repository = image[:tagSep] + tag := image[tagSep+1:] + if repository == "" || tag == "" { + return "", "", fmt.Errorf("invalid OCI image reference %q: expected name:tag or name@digest", image) + } + + return repository, image, nil +} + +func lastTagSeparatorIndex(ref string) int { + // Only treat the last ':' after the last '/' as a tag separator. + // This avoids breaking registry host:port cases like "myregistry:5000/repo/image:v1.0". + lastSlash := strings.LastIndex(ref, "/") + lastColon := strings.LastIndex(ref, ":") + if lastColon > lastSlash { + return lastColon + } + return -1 +} + +func getUKIDigestFromNestedManifest(ctx context.Context, imageRef, architecture string) (string, error) { + resolver := docker.NewResolver(docker.ResolverOptions{}) + name, desc, err := resolver.Resolve(ctx, imageRef) + if err != nil { + return "", fmt.Errorf("failed to resolve image reference: %w", err) + } + + manifestData, err := fetchContent(ctx, resolver, name, desc) + if err != nil { + return "", fmt.Errorf("failed to fetch manifest data: %w", err) + } + + var manifest ocispec.Manifest + if desc.MediaType == ocispec.MediaTypeImageIndex { + var indexManifest ocispec.Index + if err := json.Unmarshal(manifestData, &indexManifest); err != nil { + return "", fmt.Errorf("failed to unmarshal index manifest: %w", err) + } + + targetManifestDesc, found := findManifestByArchitecture(indexManifest, architecture) + if !found { + return "", fmt.Errorf("failed to find target manifest with architecture %s", architecture) + } + + nestedData, err := fetchContent(ctx, resolver, name, targetManifestDesc) + if err != nil { + return "", fmt.Errorf("failed to fetch nested manifest: %w", err) + } + + if err := json.Unmarshal(nestedData, &manifest); err != nil { + return "", fmt.Errorf("failed to unmarshal nested manifest: %w", err) + } + } else { + if err := json.Unmarshal(manifestData, &manifest); err != nil { + return "", fmt.Errorf("failed to unmarshal manifest: %w", err) + } + } + + for _, layer := range manifest.Layers { + if layer.MediaType == MediaTypeUKI { + return layer.Digest.String(), nil + } + } + + return "", fmt.Errorf("UKI layer digest not found") +} + +func findManifestByArchitecture(indexManifest ocispec.Index, architecture string) (ocispec.Descriptor, bool) { + for _, entry := range indexManifest.Manifests { + if entry.Platform != nil && entry.Platform.Architecture == architecture { + return entry, true + } + } + return ocispec.Descriptor{}, false +} + +func fetchContent(ctx context.Context, resolver remotes.Resolver, ref string, desc ocispec.Descriptor) ([]byte, error) { + fetcher, err := resolver.Fetcher(ctx, ref) + if err != nil { + return nil, fmt.Errorf("failed to get fetcher: %w", err) + } + + reader, err := fetcher.Fetch(ctx, desc) + if err != nil { + return nil, fmt.Errorf("failed to fetch content: %w", err) + } + + defer func() { + if cerr := reader.Close(); cerr != nil { + fmt.Printf("failed to close reader: %v\n", cerr) + } + }() + + data, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("failed to read content: %w", err) + } + + if int64(len(data)) != desc.Size { + return nil, fmt.Errorf("size mismatch: expected %d, got %d", desc.Size, len(data)) + } + + return data, nil +} diff --git a/internal/uki/oci_test.go b/internal/uki/oci_test.go new file mode 100644 index 0000000..9efcf20 --- /dev/null +++ b/internal/uki/oci_test.go @@ -0,0 +1,123 @@ +package uki + +import ( + "testing" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +func TestParseOCIReferenceForUKI(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + image string + wantRepo string + wantRef string + wantErr bool + }{ + { + name: "registry host port with tag", + image: "myregistry:5000/repo/image:v1.0", + wantRepo: "myregistry:5000/repo/image", + wantRef: "myregistry:5000/repo/image:v1.0", + }, + { + name: "simple tag", + image: "repo/image:v1.0", + wantRepo: "repo/image", + wantRef: "repo/image:v1.0", + }, + { + name: "digest only", + image: "repo/image@sha256:deadbeef", + wantRepo: "repo/image", + wantRef: "repo/image@sha256:deadbeef", + }, + { + name: "host port with digest", + image: "myregistry:5000/repo/image@sha256:deadbeef", + wantRepo: "myregistry:5000/repo/image", + wantRef: "myregistry:5000/repo/image@sha256:deadbeef", + }, + { + name: "tag plus digest keeps repo without tag", + image: "repo/image:v1.0@sha256:deadbeef", + wantRepo: "repo/image", + wantRef: "repo/image:v1.0@sha256:deadbeef", + }, + { + name: "missing tag and digest", + image: "repo/image", + wantErr: true, + }, + { + name: "empty tag", + image: "repo/image:", + wantErr: true, + }, + { + name: "empty digest", + image: "repo/image@", + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + gotRepo, gotRef, err := parseOCIReferenceForUKI(tt.image) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got nil (repo=%q, ref=%q)", gotRepo, gotRef) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotRepo != tt.wantRepo { + t.Fatalf("repo: got %q, want %q", gotRepo, tt.wantRepo) + } + if gotRef != tt.wantRef { + t.Fatalf("ref: got %q, want %q", gotRef, tt.wantRef) + } + }) + } +} + +func TestFindManifestByArchitecture(t *testing.T) { + t.Parallel() + + d1 := ocispec.Descriptor{ + Digest: "sha256:1111111111111111111111111111111111111111111111111111111111111111", + Platform: &ocispec.Platform{ + Architecture: "amd64", + OS: "linux", + }, + } + d2 := ocispec.Descriptor{ + Digest: "sha256:2222222222222222222222222222222222222222222222222222222222222222", + Platform: &ocispec.Platform{ + Architecture: "arm64", + OS: "linux", + }, + } + + index := ocispec.Index{Manifests: []ocispec.Descriptor{d1, d2}} + + got, ok := findManifestByArchitecture(index, "arm64") + if !ok { + t.Fatalf("expected ok=true") + } + if got.Digest != d2.Digest { + t.Fatalf("got digest %q, want %q", got.Digest, d2.Digest) + } + + _, ok = findManifestByArchitecture(index, "ppc64le") + if ok { + t.Fatalf("expected ok=false") + } +} diff --git a/server/bootserver.go b/server/bootserver.go index f1a9bf4..2c01186 100644 --- a/server/bootserver.go +++ b/server/bootserver.go @@ -23,6 +23,8 @@ import ( butanecommon "github.com/coreos/butane/config/common" "github.com/go-logr/logr" bootv1alpha1 "github.com/ironcore-dev/boot-operator/api/v1alpha1" + "github.com/ironcore-dev/boot-operator/internal/registry" + "github.com/ironcore-dev/boot-operator/internal/uki" ) type IPXETemplateData struct { @@ -48,13 +50,23 @@ var predefinedConditions = map[string]v1.Condition{ }, } -func RunBootServer(ipxeServerAddr string, ipxeServiceURL string, k8sClient client.Client, log logr.Logger, defaultUKIURL string) error { +func RunBootServer( + ipxeServerAddr string, + ipxeServiceURL string, + k8sClient client.Client, + log logr.Logger, + registryValidator *registry.Validator, + defaultOCIImage string, + defaultUKIURL string, + imageServerURL string, + architecture string, +) error { http.HandleFunc("/ipxe/", func(w http.ResponseWriter, r *http.Request) { handleIPXE(w, r, k8sClient, log, ipxeServiceURL) }) http.HandleFunc("/httpboot", func(w http.ResponseWriter, r *http.Request) { - handleHTTPBoot(w, r, k8sClient, log, defaultUKIURL) + handleHTTPBoot(w, r, k8sClient, log, registryValidator, defaultOCIImage, defaultUKIURL, imageServerURL, architecture) }) http.HandleFunc("/ignition/", func(w http.ResponseWriter, r *http.Request) { @@ -371,7 +383,17 @@ func renderIgnition(yamlData []byte) ([]byte, error) { return jsonData, nil } -func handleHTTPBoot(w http.ResponseWriter, r *http.Request, k8sClient client.Client, log logr.Logger, defaultUKIURL string) { +func handleHTTPBoot( + w http.ResponseWriter, + r *http.Request, + k8sClient client.Client, + log logr.Logger, + registryValidator *registry.Validator, + defaultOCIImage string, + defaultUKIURL string, + imageServerURL string, + architecture string, +) { log.Info("Processing HTTPBoot request", "method", r.Method, "path", r.URL.Path, "clientIP", r.RemoteAddr) ctx := r.Context() @@ -409,9 +431,42 @@ func handleHTTPBoot(w http.ResponseWriter, r *http.Request, k8sClient client.Cli var httpBootResponseData map[string]string if len(httpBootConfigs.Items) == 0 { log.Info("No HTTPBootConfig found for client IP, delivering default httpboot data", "clientIPs", clientIPs) + if defaultOCIImage == "" && defaultUKIURL == "" { + log.Error( + fmt.Errorf("no default UKI configured"), + "Both defaultOCIImage and defaultUKIURL are empty; refusing to return an empty UKIURL", + "defaultOCIImage", defaultOCIImage, + "defaultUKIURL", defaultUKIURL, + ) + http.Error(w, "HTTP boot is not configured (missing default UKI)", http.StatusInternalServerError) + return + } + + ukiURL := defaultUKIURL + if defaultOCIImage != "" { + if registryValidator != nil { + if err := registryValidator.ValidateImageRegistry(defaultOCIImage); err != nil { + log.Error(err, "Default OCI image rejected by registry allowlist", "image", defaultOCIImage) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + } + if strings.TrimSpace(imageServerURL) == "" { + log.Error(fmt.Errorf("image server URL is empty"), "Default OCI image provided but image server URL is not set") + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + constructedURL, err := uki.ConstructUKIURLFromOCI(ctx, defaultOCIImage, imageServerURL, architecture) + if err != nil { + log.Error(err, "Failed to construct default UKI URL from OCI image", "image", defaultOCIImage) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + ukiURL = constructedURL + } httpBootResponseData = map[string]string{ "ClientIPs": strings.Join(clientIPs, ","), - "UKIURL": defaultUKIURL, + "UKIURL": ukiURL, } } else { // TODO: Pick the first HttpBootConfig if multiple CRs are found. diff --git a/server/bootserver_suit_test.go b/server/bootserver_suit_test.go index de847b8..37a54b3 100644 --- a/server/bootserver_suit_test.go +++ b/server/bootserver_suit_test.go @@ -10,6 +10,7 @@ import ( "github.com/go-logr/logr" bootv1alpha1 "github.com/ironcore-dev/boot-operator/api/v1alpha1" + "github.com/ironcore-dev/boot-operator/internal/registry" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" @@ -46,7 +47,17 @@ var _ = BeforeSuite(func() { testLog := logr.Discard() go func() { defer GinkgoRecover() - errCh <- RunBootServer(testServerAddr, ipxeServiceURL, k8sClient, testLog, defaultUKIURL) + errCh <- RunBootServer( + testServerAddr, + ipxeServiceURL, + k8sClient, + testLog, + registry.NewValidator(""), + "", + defaultUKIURL, + "", + "amd64", + ) }() Eventually(func() error { From ac3c85a0d00377ac28382db4ac8dd0c894e69179 Mon Sep 17 00:00:00 2001 From: Hardik Dodiya Date: Wed, 18 Mar 2026 13:30:42 +0100 Subject: [PATCH 2/2] Refactor to reuse the image validation across ipxe and httpboot --- Dockerfile | 4 +- .../controller/serverbootconfig_helpers.go | 102 -------------- ...serverbootconfiguration_http_controller.go | 3 +- .../serverbootconfiguration_pxe_controller.go | 6 +- internal/oci/manifest.go | 130 ++++++++++++++++++ internal/uki/oci.go | 73 +--------- internal/uki/oci_test.go | 37 ----- 7 files changed, 141 insertions(+), 214 deletions(-) create mode 100644 internal/oci/manifest.go diff --git a/Dockerfile b/Dockerfile index 95d10a1..4ea2d02 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,9 +14,7 @@ RUN go mod download # Copy the go source COPY cmd/main.go cmd/main.go COPY api/ api/ -COPY internal/controller/ internal/controller/ -COPY internal/registry/ internal/registry/ -COPY internal/uki/ internal/uki/ +COPY internal/ internal/ COPY server/ server/ COPY templates/ templates/ diff --git a/internal/controller/serverbootconfig_helpers.go b/internal/controller/serverbootconfig_helpers.go index af57c60..6a0b00a 100644 --- a/internal/controller/serverbootconfig_helpers.go +++ b/internal/controller/serverbootconfig_helpers.go @@ -18,15 +18,11 @@ package controller import ( "context" - "encoding/json" "fmt" - "io" "strings" - "github.com/containerd/containerd/remotes" "github.com/distribution/reference" metalv1alpha1 "github.com/ironcore-dev/metal-operator/api/v1alpha1" - ocispec "github.com/opencontainers/image-spec/specs-go/v1" corev1 "k8s.io/api/core/v1" apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -68,73 +64,6 @@ func BuildImageReference(imageName, imageVersion string) string { return fmt.Sprintf("%s:%s", imageName, imageVersion) } -// FindManifestByArchitecture navigates an OCI image index to find the manifest for a specific architecture. -// If enableCNAMECompat is true, it first tries to find manifests using the legacy CNAME annotation approach. -// Returns the architecture-specific manifest, or an error if not found. -func FindManifestByArchitecture(ctx context.Context, resolver remotes.Resolver, name string, desc ocispec.Descriptor, architecture string, enableCNAMECompat bool) (ocispec.Manifest, error) { - manifestData, err := fetchContent(ctx, resolver, name, desc) - if err != nil { - return ocispec.Manifest{}, fmt.Errorf("failed to fetch manifest data: %w", err) - } - - var manifest ocispec.Manifest - if err := json.Unmarshal(manifestData, &manifest); err != nil { - return ocispec.Manifest{}, fmt.Errorf("failed to unmarshal manifest: %w", err) - } - - // If not an index, return the manifest directly - if desc.MediaType != ocispec.MediaTypeImageIndex { - return manifest, nil - } - - // Parse as index and find architecture-specific manifest - var indexManifest ocispec.Index - if err := json.Unmarshal(manifestData, &indexManifest); err != nil { - return ocispec.Manifest{}, fmt.Errorf("failed to unmarshal index manifest: %w", err) - } - - var targetManifestDesc ocispec.Descriptor - - // Backward compatibility for CNAME prefix based OCI (PXE only) - if enableCNAMECompat { - for _, m := range indexManifest.Manifests { - if strings.HasPrefix(m.Annotations["cname"], CNAMEPrefixMetalPXE) { - if m.Annotations["architecture"] == architecture { - targetManifestDesc = m - break - } - } - } - } - - // Standard platform-based architecture lookup - if targetManifestDesc.Digest == "" { - for _, m := range indexManifest.Manifests { - if m.Platform != nil && m.Platform.Architecture == architecture { - targetManifestDesc = m - break - } - } - } - - if targetManifestDesc.Digest == "" { - return ocispec.Manifest{}, fmt.Errorf("failed to find target manifest with architecture %s", architecture) - } - - // Fetch the nested manifest - nestedData, err := fetchContent(ctx, resolver, name, targetManifestDesc) - if err != nil { - return ocispec.Manifest{}, fmt.Errorf("failed to fetch nested manifest: %w", err) - } - - var nestedManifest ocispec.Manifest - if err := json.Unmarshal(nestedData, &nestedManifest); err != nil { - return ocispec.Manifest{}, fmt.Errorf("failed to unmarshal nested manifest: %w", err) - } - - return nestedManifest, nil -} - // ExtractServerNetworkIDs extracts IP addresses (and optionally MAC addresses) from a Server's network interfaces. // Returns a slice of IP addresses as strings. If includeMACAddresses is true, MAC addresses are also included. func ExtractServerNetworkIDs(server *metalv1alpha1.Server, includeMACAddresses bool) []string { @@ -189,37 +118,6 @@ func EnqueueServerBootConfigsReferencingSecret(ctx context.Context, c client.Cli return requests } -// fetchContent fetches the content of an OCI descriptor using the provided resolver. -// It validates the content size matches the descriptor and returns the raw bytes. -func fetchContent(ctx context.Context, resolver remotes.Resolver, ref string, desc ocispec.Descriptor) ([]byte, error) { - fetcher, err := resolver.Fetcher(ctx, ref) - if err != nil { - return nil, fmt.Errorf("failed to get fetcher: %w", err) - } - - reader, err := fetcher.Fetch(ctx, desc) - if err != nil { - return nil, fmt.Errorf("failed to fetch content: %w", err) - } - - defer func() { - if cerr := reader.Close(); cerr != nil { - fmt.Printf("failed to close reader: %v\n", cerr) - } - }() - - data, err := io.ReadAll(reader) - if err != nil { - return nil, fmt.Errorf("failed to read content: %w", err) - } - - if int64(len(data)) != desc.Size { - return nil, fmt.Errorf("size mismatch: expected %d, got %d", desc.Size, len(data)) - } - - return data, nil -} - // PatchServerBootConfigWithError updates the ServerBootConfiguration state to Error // and sets an ImageValidation condition with the error details. func PatchServerBootConfigWithError( diff --git a/internal/controller/serverbootconfiguration_http_controller.go b/internal/controller/serverbootconfiguration_http_controller.go index 7f80112..567b6af 100644 --- a/internal/controller/serverbootconfiguration_http_controller.go +++ b/internal/controller/serverbootconfiguration_http_controller.go @@ -12,6 +12,7 @@ import ( apimeta "k8s.io/apimachinery/pkg/api/meta" "github.com/containerd/containerd/remotes/docker" + "github.com/ironcore-dev/boot-operator/internal/oci" "github.com/ironcore-dev/boot-operator/internal/registry" corev1 "k8s.io/api/core/v1" @@ -214,7 +215,7 @@ func (r *ServerBootConfigurationHTTPReconciler) getUKIDigestFromNestedManifest(c return "", fmt.Errorf("failed to resolve image reference: %w", err) } - manifest, err := FindManifestByArchitecture(ctx, resolver, name, desc, r.Architecture, false) + manifest, err := oci.FindManifestByArchitecture(ctx, resolver, name, desc, r.Architecture, oci.FindManifestOptions{}) if err != nil { return "", err } diff --git a/internal/controller/serverbootconfiguration_pxe_controller.go b/internal/controller/serverbootconfiguration_pxe_controller.go index 30ef100..5bebc00 100644 --- a/internal/controller/serverbootconfiguration_pxe_controller.go +++ b/internal/controller/serverbootconfiguration_pxe_controller.go @@ -27,6 +27,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" "github.com/ironcore-dev/boot-operator/api/v1alpha1" + "github.com/ironcore-dev/boot-operator/internal/oci" "github.com/ironcore-dev/boot-operator/internal/registry" apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -229,7 +230,10 @@ func (r *ServerBootConfigurationPXEReconciler) getLayerDigestsFromNestedManifest return "", "", "", fmt.Errorf("failed to resolve image reference: %w", err) } - manifest, err := FindManifestByArchitecture(ctx, resolver, name, desc, r.Architecture, true) + manifest, err := oci.FindManifestByArchitecture(ctx, resolver, name, desc, r.Architecture, oci.FindManifestOptions{ + EnableCNAMECompat: true, + CNAMEPrefix: CNAMEPrefixMetalPXE, + }) if err != nil { return "", "", "", err } diff --git a/internal/oci/manifest.go b/internal/oci/manifest.go new file mode 100644 index 0000000..5385670 --- /dev/null +++ b/internal/oci/manifest.go @@ -0,0 +1,130 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package oci + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/containerd/containerd/remotes" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +type FindManifestOptions struct { + // EnableCNAMECompat enables a legacy manifest selection mode based on annotations. + // This is currently only needed for backward compatibility with older PXE images. + EnableCNAMECompat bool + // CNAMEPrefix is the required prefix for the "cname" annotation when EnableCNAMECompat is set. + CNAMEPrefix string +} + +const ( + annotationCNAME = "cname" + annotationArchitecture = "architecture" +) + +// FindManifestByArchitecture navigates an OCI image index to find the manifest for a specific architecture. +// If opts.EnableCNAMECompat is true, it first tries to find a manifest using the legacy CNAME annotation approach. +func FindManifestByArchitecture( + ctx context.Context, + resolver remotes.Resolver, + name string, + desc ocispec.Descriptor, + architecture string, + opts FindManifestOptions, +) (ocispec.Manifest, error) { + manifestData, err := FetchContent(ctx, resolver, name, desc) + if err != nil { + return ocispec.Manifest{}, fmt.Errorf("failed to fetch manifest data: %w", err) + } + + var manifest ocispec.Manifest + if err := json.Unmarshal(manifestData, &manifest); err != nil { + return ocispec.Manifest{}, fmt.Errorf("failed to unmarshal manifest: %w", err) + } + + // If not an index, return the manifest directly. + if desc.MediaType != ocispec.MediaTypeImageIndex { + return manifest, nil + } + + // Parse as index and find architecture-specific manifest. + var indexManifest ocispec.Index + if err := json.Unmarshal(manifestData, &indexManifest); err != nil { + return ocispec.Manifest{}, fmt.Errorf("failed to unmarshal index manifest: %w", err) + } + + var targetManifestDesc ocispec.Descriptor + + // Backward compatibility for CNAME-prefix based OCI. + if opts.EnableCNAMECompat && strings.TrimSpace(opts.CNAMEPrefix) != "" { + for _, m := range indexManifest.Manifests { + if strings.HasPrefix(m.Annotations[annotationCNAME], opts.CNAMEPrefix) && m.Annotations[annotationArchitecture] == architecture { + targetManifestDesc = m + break + } + } + } + + // Standard platform-based architecture lookup. + if targetManifestDesc.Digest == "" { + for _, m := range indexManifest.Manifests { + if m.Platform != nil && m.Platform.Architecture == architecture { + targetManifestDesc = m + break + } + } + } + + if targetManifestDesc.Digest == "" { + return ocispec.Manifest{}, fmt.Errorf("failed to find target manifest with architecture %s", architecture) + } + + // Fetch the nested manifest. + nestedData, err := FetchContent(ctx, resolver, name, targetManifestDesc) + if err != nil { + return ocispec.Manifest{}, fmt.Errorf("failed to fetch nested manifest: %w", err) + } + + var nestedManifest ocispec.Manifest + if err := json.Unmarshal(nestedData, &nestedManifest); err != nil { + return ocispec.Manifest{}, fmt.Errorf("failed to unmarshal nested manifest: %w", err) + } + + return nestedManifest, nil +} + +// FetchContent fetches the content of an OCI descriptor using the provided resolver. +// It validates the content size matches the descriptor and returns the raw bytes. +func FetchContent(ctx context.Context, resolver remotes.Resolver, ref string, desc ocispec.Descriptor) ([]byte, error) { + fetcher, err := resolver.Fetcher(ctx, ref) + if err != nil { + return nil, fmt.Errorf("failed to get fetcher: %w", err) + } + + reader, err := fetcher.Fetch(ctx, desc) + if err != nil { + return nil, fmt.Errorf("failed to fetch content: %w", err) + } + + defer func() { + if cerr := reader.Close(); cerr != nil { + fmt.Printf("failed to close reader: %v\n", cerr) + } + }() + + data, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("failed to read content: %w", err) + } + + if int64(len(data)) != desc.Size { + return nil, fmt.Errorf("size mismatch: expected %d, got %d", desc.Size, len(data)) + } + + return data, nil +} diff --git a/internal/uki/oci.go b/internal/uki/oci.go index cd13138..3795883 100644 --- a/internal/uki/oci.go +++ b/internal/uki/oci.go @@ -5,14 +5,11 @@ package uki import ( "context" - "encoding/json" "fmt" - "io" "strings" - "github.com/containerd/containerd/remotes" "github.com/containerd/containerd/remotes/docker" - ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/ironcore-dev/boot-operator/internal/oci" ) const MediaTypeUKI = "application/vnd.ironcore.image.uki" @@ -84,35 +81,9 @@ func getUKIDigestFromNestedManifest(ctx context.Context, imageRef, architecture return "", fmt.Errorf("failed to resolve image reference: %w", err) } - manifestData, err := fetchContent(ctx, resolver, name, desc) + manifest, err := oci.FindManifestByArchitecture(ctx, resolver, name, desc, architecture, oci.FindManifestOptions{}) if err != nil { - return "", fmt.Errorf("failed to fetch manifest data: %w", err) - } - - var manifest ocispec.Manifest - if desc.MediaType == ocispec.MediaTypeImageIndex { - var indexManifest ocispec.Index - if err := json.Unmarshal(manifestData, &indexManifest); err != nil { - return "", fmt.Errorf("failed to unmarshal index manifest: %w", err) - } - - targetManifestDesc, found := findManifestByArchitecture(indexManifest, architecture) - if !found { - return "", fmt.Errorf("failed to find target manifest with architecture %s", architecture) - } - - nestedData, err := fetchContent(ctx, resolver, name, targetManifestDesc) - if err != nil { - return "", fmt.Errorf("failed to fetch nested manifest: %w", err) - } - - if err := json.Unmarshal(nestedData, &manifest); err != nil { - return "", fmt.Errorf("failed to unmarshal nested manifest: %w", err) - } - } else { - if err := json.Unmarshal(manifestData, &manifest); err != nil { - return "", fmt.Errorf("failed to unmarshal manifest: %w", err) - } + return "", err } for _, layer := range manifest.Layers { @@ -123,41 +94,3 @@ func getUKIDigestFromNestedManifest(ctx context.Context, imageRef, architecture return "", fmt.Errorf("UKI layer digest not found") } - -func findManifestByArchitecture(indexManifest ocispec.Index, architecture string) (ocispec.Descriptor, bool) { - for _, entry := range indexManifest.Manifests { - if entry.Platform != nil && entry.Platform.Architecture == architecture { - return entry, true - } - } - return ocispec.Descriptor{}, false -} - -func fetchContent(ctx context.Context, resolver remotes.Resolver, ref string, desc ocispec.Descriptor) ([]byte, error) { - fetcher, err := resolver.Fetcher(ctx, ref) - if err != nil { - return nil, fmt.Errorf("failed to get fetcher: %w", err) - } - - reader, err := fetcher.Fetch(ctx, desc) - if err != nil { - return nil, fmt.Errorf("failed to fetch content: %w", err) - } - - defer func() { - if cerr := reader.Close(); cerr != nil { - fmt.Printf("failed to close reader: %v\n", cerr) - } - }() - - data, err := io.ReadAll(reader) - if err != nil { - return nil, fmt.Errorf("failed to read content: %w", err) - } - - if int64(len(data)) != desc.Size { - return nil, fmt.Errorf("size mismatch: expected %d, got %d", desc.Size, len(data)) - } - - return data, nil -} diff --git a/internal/uki/oci_test.go b/internal/uki/oci_test.go index 9efcf20..139ccdd 100644 --- a/internal/uki/oci_test.go +++ b/internal/uki/oci_test.go @@ -2,8 +2,6 @@ package uki import ( "testing" - - ocispec "github.com/opencontainers/image-spec/specs-go/v1" ) func TestParseOCIReferenceForUKI(t *testing.T) { @@ -64,7 +62,6 @@ func TestParseOCIReferenceForUKI(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() @@ -87,37 +84,3 @@ func TestParseOCIReferenceForUKI(t *testing.T) { }) } } - -func TestFindManifestByArchitecture(t *testing.T) { - t.Parallel() - - d1 := ocispec.Descriptor{ - Digest: "sha256:1111111111111111111111111111111111111111111111111111111111111111", - Platform: &ocispec.Platform{ - Architecture: "amd64", - OS: "linux", - }, - } - d2 := ocispec.Descriptor{ - Digest: "sha256:2222222222222222222222222222222222222222222222222222222222222222", - Platform: &ocispec.Platform{ - Architecture: "arm64", - OS: "linux", - }, - } - - index := ocispec.Index{Manifests: []ocispec.Descriptor{d1, d2}} - - got, ok := findManifestByArchitecture(index, "arm64") - if !ok { - t.Fatalf("expected ok=true") - } - if got.Digest != d2.Digest { - t.Fatalf("got digest %q, want %q", got.Digest, d2.Digest) - } - - _, ok = findManifestByArchitecture(index, "ppc64le") - if ok { - t.Fatalf("expected ok=false") - } -}