Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ RUN go mod download
COPY cmd/main.go cmd/main.go
COPY api/ api/
COPY internal/controller/ internal/controller/
COPY internal/registry/ internal/registry/
COPY server/ server/
COPY templates/ templates/

Expand Down
25 changes: 16 additions & 9 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import (

bootv1alpha1 "github.com/ironcore-dev/boot-operator/api/v1alpha1"
"github.com/ironcore-dev/boot-operator/internal/controller"
"github.com/ironcore-dev/boot-operator/internal/registry"
bootserver "github.com/ironcore-dev/boot-operator/server"
//+kubebuilder:scaffold:imports
)
Expand Down Expand Up @@ -227,6 +228,10 @@ func main() {
os.Exit(1)
}

// Initialize registry validator for OCI image validation
registryValidator := registry.NewValidator()
setupLog.Info("Initialized registry validator", "allowedRegistries", os.Getenv("ALLOWED_REGISTRIES"), "blockedRegistries", os.Getenv("BLOCKED_REGISTRIES"))

if controllers.Enabled(ipxeBootConfigController) {
if err = (&controller.IPXEBootConfigReconciler{
Client: mgr.GetClient(),
Expand All @@ -239,10 +244,11 @@ func main() {

if controllers.Enabled(serverBootConfigControllerPxe) {
if err = (&controller.ServerBootConfigurationPXEReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
IPXEServiceURL: ipxeServiceURL,
Architecture: architecture,
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
IPXEServiceURL: ipxeServiceURL,
Architecture: architecture,
RegistryValidator: registryValidator,
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "ServerBootConfigPxe")
os.Exit(1)
Expand All @@ -251,10 +257,11 @@ func main() {

if controllers.Enabled(serverBootConfigControllerHttp) {
if err = (&controller.ServerBootConfigurationHTTPReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
ImageServerURL: imageServerURL,
Architecture: architecture,
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
ImageServerURL: imageServerURL,
Architecture: architecture,
RegistryValidator: registryValidator,
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "ServerBootConfigHttp")
os.Exit(1)
Expand Down Expand Up @@ -311,7 +318,7 @@ func main() {
}()

setupLog.Info("starting image-proxy-server")
go bootserver.RunImageProxyServer(imageProxyServerAddr, mgr.GetClient(), serverLog.WithName("imageproxyserver"))
go bootserver.RunImageProxyServer(imageProxyServerAddr, mgr.GetClient(), registryValidator, serverLog.WithName("imageproxyserver"))

setupLog.Info("starting manager")
if err := mgr.Start(ctx); err != nil {
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ go 1.25.6
require (
github.com/containerd/containerd v1.7.30
github.com/coreos/butane v0.26.0
github.com/distribution/reference v0.6.0
github.com/go-logr/logr v1.4.3
github.com/ironcore-dev/controller-utils v0.11.0
github.com/ironcore-dev/metal v0.0.0-20240624131301-18385f342755
github.com/ironcore-dev/metal-operator v0.3.0
github.com/onsi/ginkgo/v2 v2.28.1
github.com/onsi/gomega v1.39.1
github.com/opencontainers/go-digest v1.0.0
github.com/opencontainers/image-spec v1.1.1
github.com/spf13/cobra v1.10.2
k8s.io/api v0.35.0
Expand Down Expand Up @@ -76,7 +78,6 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
Expand Down
219 changes: 219 additions & 0 deletions internal/controller/serverbootconfig_helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
/*
Copyright 2024.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

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"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
)

// ParseImageReference parses an OCI image reference and returns the image name and version.
// It handles tagged references, digest references, and untagged references (defaulting to "latest").
func ParseImageReference(image string) (imageName, imageVersion string, err error) {
named, err := reference.ParseNormalizedNamed(image)
if err != nil {
return "", "", fmt.Errorf("invalid image reference: %w", err)
}

if tagged, ok := named.(reference.Tagged); ok {
imageName = reference.FamiliarName(named)
imageVersion = tagged.Tag()
} else if canonical, ok := named.(reference.Canonical); ok {
imageName = reference.FamiliarName(named)
imageVersion = canonical.Digest().String()
} else {
// No tag or digest, use "latest" as default
imageName = reference.FamiliarName(named)
imageVersion = "latest"
}

return imageName, imageVersion, nil
}

// BuildImageReference constructs a properly formatted OCI image reference from name and version.
// Uses @ separator for digest-based references (sha256:..., sha512:...) and : for tags.
func BuildImageReference(imageName, imageVersion string) string {
if strings.HasPrefix(imageVersion, "sha256:") || strings.HasPrefix(imageVersion, "sha512:") {
return fmt.Sprintf("%s@%s", imageName, imageVersion)
}
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 {
ids := make([]string, 0, len(server.Status.NetworkInterfaces))

for _, nic := range server.Status.NetworkInterfaces {
// Add IPs
if len(nic.IPs) > 0 {
for _, ip := range nic.IPs {
ids = append(ids, ip.String())
}
} else if nic.IP != nil && !nic.IP.IsZero() {
ids = append(ids, nic.IP.String())
}

// Add MAC address if requested
if includeMACAddresses && nic.MACAddress != "" {
ids = append(ids, nic.MACAddress)
}
}

return ids
}

// EnqueueServerBootConfigsReferencingSecret finds all ServerBootConfigurations in the same namespace
// that reference the given Secret via IgnitionSecretRef and returns reconcile requests for them.
func EnqueueServerBootConfigsReferencingSecret(ctx context.Context, c client.Client, secret client.Object) []reconcile.Request {
log := ctrl.LoggerFrom(ctx)
secretObj, ok := secret.(*corev1.Secret)
if !ok {
log.Error(nil, "Failed to decode object into Secret", "object", secret)
return nil
}

bootConfigList := &metalv1alpha1.ServerBootConfigurationList{}
if err := c.List(ctx, bootConfigList, client.InNamespace(secretObj.Namespace)); err != nil {
log.Error(err, "Failed to list ServerBootConfiguration for Secret", "Secret", client.ObjectKeyFromObject(secretObj))
return nil
}

var requests []reconcile.Request
for _, bootConfig := range bootConfigList.Items {
if bootConfig.Spec.IgnitionSecretRef != nil && bootConfig.Spec.IgnitionSecretRef.Name == secretObj.Name {
requests = append(requests, reconcile.Request{
NamespacedName: types.NamespacedName{
Name: bootConfig.Name,
Namespace: bootConfig.Namespace,
},
})
}
}
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
}
63 changes: 63 additions & 0 deletions internal/controller/serverbootconfig_helpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors
// SPDX-License-Identifier: Apache-2.0

package controller

import (
"testing"
)

func TestBuildImageReference(t *testing.T) {
tests := []struct {
name string
imageName string
imageVersion string
want string
}{
{
name: "tagged reference with simple tag",
imageName: "ghcr.io/ironcore-dev/gardenlinux",
imageVersion: "v1.0.0",
want: "ghcr.io/ironcore-dev/gardenlinux:v1.0.0",
},
{
name: "tagged reference with latest",
imageName: "docker.io/library/ubuntu",
imageVersion: "latest",
want: "docker.io/library/ubuntu:latest",
},
{
name: "digest reference with sha256",
imageName: "ghcr.io/ironcore-dev/gardenlinux",
imageVersion: "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
want: "ghcr.io/ironcore-dev/gardenlinux@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
},
{
name: "digest reference with sha512",
imageName: "registry.example.com/myimage",
imageVersion: "sha512:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
want: "registry.example.com/myimage@sha512:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
},
{
name: "tagged reference with numeric tag",
imageName: "localhost:5000/testimage",
imageVersion: "1.2.3",
want: "localhost:5000/testimage:1.2.3",
},
{
name: "tagged reference with complex tag",
imageName: "keppel.global.cloud.sap/ironcore/gardenlinux-iso",
imageVersion: "arm64-v1.0.0-alpha",
want: "keppel.global.cloud.sap/ironcore/gardenlinux-iso:arm64-v1.0.0-alpha",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := BuildImageReference(tt.imageName, tt.imageVersion)
if got != tt.want {
t.Errorf("BuildImageReference(%q, %q) = %q, want %q", tt.imageName, tt.imageVersion, got, tt.want)
}
})
}
}
Loading
Loading