Skip to content

Commit 3406c96

Browse files
mabulguclaude
andcommitted
Add per-host OCI registry authentication for provisioning images
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: mabulgu <mabulgu@gmail.com>
1 parent c202b9a commit 3406c96

13 files changed

Lines changed: 286 additions & 9 deletions

File tree

apis/metal3.io/v1alpha1/baremetalhost_types.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -648,14 +648,24 @@ type Image struct {
648648
// be live-booted and not deployed to disk.
649649
// +kubebuilder:validation:Enum=raw;qcow2;vdi;vmdk;live-iso
650650
DiskFormat *string `json:"format,omitempty"`
651+
652+
// OCIAuthSecretName optionally names a Docker-config secret containing
653+
// registry credentials for oci:// images. Must be in the same namespace
654+
// as the BareMetalHost. Allowed types: kubernetes.io/dockerconfigjson|dockercfg.
655+
// Only used when Image.URL has the oci:// scheme.
656+
OCIAuthSecretName *string `json:"ociAuthSecretName,omitempty"`
651657
}
652658

653659
func (image *Image) IsLiveISO() bool {
654660
return image != nil && image.DiskFormat != nil && *image.DiskFormat == "live-iso"
655661
}
656662

663+
// IsOCI returns true if the image URL uses the OCI scheme (oci://).
657664
func (image *Image) IsOCI() bool {
658-
return image != nil && strings.HasPrefix(image.URL, "oci://")
665+
if image == nil || image.URL == "" {
666+
return false
667+
}
668+
return strings.HasPrefix(strings.ToLower(image.URL), "oci://")
659669
}
660670

661671
// Custom deploy is a description of a customized deploy process.

apis/metal3.io/v1alpha1/zz_generated.deepcopy.go

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

config/base/crds/bases/metal3.io_baremetalhosts.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,13 @@ spec:
291291
- vmdk
292292
- live-iso
293293
type: string
294+
ociAuthSecretName:
295+
description: |-
296+
OCIAuthSecretName optionally names a Docker-config secret containing
297+
registry credentials for oci:// images. Must be in the same namespace
298+
as the BareMetalHost. Allowed types: kubernetes.io/dockerconfigjson|dockercfg.
299+
Only used when Image.URL has the oci:// scheme.
300+
type: string
294301
url:
295302
description: URL is a location of an image to deploy.
296303
type: string
@@ -1092,6 +1099,13 @@ spec:
10921099
- vmdk
10931100
- live-iso
10941101
type: string
1102+
ociAuthSecretName:
1103+
description: |-
1104+
OCIAuthSecretName optionally names a Docker-config secret containing
1105+
registry credentials for oci:// images. Must be in the same namespace
1106+
as the BareMetalHost. Allowed types: kubernetes.io/dockerconfigjson|dockercfg.
1107+
Only used when Image.URL has the oci:// scheme.
1108+
type: string
10951109
url:
10961110
description: URL is a location of an image to deploy.
10971111
type: string

config/base/crds/bases/metal3.io_hostclaims.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,13 @@ spec:
184184
- vmdk
185185
- live-iso
186186
type: string
187+
ociAuthSecretName:
188+
description: |-
189+
OCIAuthSecretName optionally names a Docker-config secret containing
190+
registry credentials for oci:// images. Must be in the same namespace
191+
as the BareMetalHost. Allowed types: kubernetes.io/dockerconfigjson|dockercfg.
192+
Only used when Image.URL has the oci:// scheme.
193+
type: string
187194
url:
188195
description: URL is a location of an image to deploy.
189196
type: string

config/render/capm3.yaml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,13 @@ spec:
291291
- vmdk
292292
- live-iso
293293
type: string
294+
ociAuthSecretName:
295+
description: |-
296+
OCIAuthSecretName optionally names a Docker-config secret containing
297+
registry credentials for oci:// images. Must be in the same namespace
298+
as the BareMetalHost. Allowed types: kubernetes.io/dockerconfigjson|dockercfg.
299+
Only used when Image.URL has the oci:// scheme.
300+
type: string
294301
url:
295302
description: URL is a location of an image to deploy.
296303
type: string
@@ -1092,6 +1099,13 @@ spec:
10921099
- vmdk
10931100
- live-iso
10941101
type: string
1102+
ociAuthSecretName:
1103+
description: |-
1104+
OCIAuthSecretName optionally names a Docker-config secret containing
1105+
registry credentials for oci:// images. Must be in the same namespace
1106+
as the BareMetalHost. Allowed types: kubernetes.io/dockerconfigjson|dockercfg.
1107+
Only used when Image.URL has the oci:// scheme.
1108+
type: string
10951109
url:
10961110
description: URL is a location of an image to deploy.
10971111
type: string
@@ -2041,6 +2055,13 @@ spec:
20412055
- vmdk
20422056
- live-iso
20432057
type: string
2058+
ociAuthSecretName:
2059+
description: |-
2060+
OCIAuthSecretName optionally names a Docker-config secret containing
2061+
registry credentials for oci:// images. Must be in the same namespace
2062+
as the BareMetalHost. Allowed types: kubernetes.io/dockerconfigjson|dockercfg.
2063+
Only used when Image.URL has the oci:// scheme.
2064+
type: string
20442065
url:
20452066
description: URL is a location of an image to deploy.
20462067
type: string

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module github.com/metal3-io/baremetal-operator
33
go 1.25.0
44

55
require (
6+
github.com/cpuguy83/dockercfg v0.3.2
67
github.com/go-logr/logr v1.4.3
78
github.com/google/safetext v0.0.0-20230106111101-7156a760e523
89
github.com/google/uuid v1.6.0

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x
1212
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
1313
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
1414
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
15+
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
16+
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
1517
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
1618
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
1719
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

internal/controller/metal3.io/baremetalhost_controller.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import (
3939
k8serrors "k8s.io/apimachinery/pkg/api/errors"
4040
"k8s.io/apimachinery/pkg/api/meta"
4141
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
42+
"k8s.io/client-go/tools/record"
4243
"sigs.k8s.io/cluster-api/util/conditions"
4344
ctrl "sigs.k8s.io/controller-runtime"
4445
"sigs.k8s.io/controller-runtime/pkg/builder"
@@ -66,6 +67,7 @@ type BareMetalHostReconciler struct {
6667
Log logr.Logger
6768
ProvisionerFactory provisioner.Factory
6869
APIReader client.Reader
70+
Recorder record.EventRecorder
6971
}
7072

7173
// Instead of passing a zillion arguments to the action of a phase,
@@ -1321,13 +1323,20 @@ func (r *BareMetalHostReconciler) actionProvisioning(ctx context.Context, prov p
13211323
image = *info.host.Spec.Image.DeepCopy()
13221324
}
13231325

1326+
// Extract OCI auth secret credentials if needed
1327+
authSecret, err := r.getImageAuthSecret(ctx, info.host, &image)
1328+
if err != nil {
1329+
return recordActionFailure(info, metal3api.ProvisioningError, err.Error())
1330+
}
1331+
13241332
provResult, err := prov.Provision(ctx, provisioner.ProvisionData{
13251333
Image: image,
13261334
CustomDeploy: info.host.Spec.CustomDeploy.DeepCopy(),
13271335
HostConfig: hostConf,
13281336
BootMode: info.host.Status.Provisioning.BootMode,
13291337
HardwareProfile: hwProf,
13301338
RootDeviceHints: info.host.Status.Provisioning.RootDeviceHints.DeepCopy(),
1339+
ImagePullSecret: authSecret,
13311340
}, forceReboot)
13321341
if err != nil {
13331342
return actionError{fmt.Errorf("failed to provision: %w", err)}
@@ -2407,6 +2416,23 @@ func (r *BareMetalHostReconciler) getBMCSecretAndSetOwner(ctx context.Context, r
24072416
return bmcCredsSecret, nil
24082417
}
24092418

2419+
// getImageAuthSecret validates and extracts the OCI registry credentials for the image.
2420+
// It returns the base64-encoded credentials in the format expected by Ironic, or an empty
2421+
// string if no auth secret is configured.
2422+
func (r *BareMetalHostReconciler) getImageAuthSecret(ctx context.Context, host *metal3api.BareMetalHost, image *metal3api.Image) (string, error) {
2423+
if image == nil || !image.IsOCI() {
2424+
return "", nil
2425+
}
2426+
2427+
if image.OCIAuthSecretName == nil || *image.OCIAuthSecretName == "" {
2428+
return "", nil
2429+
}
2430+
2431+
secretManager := r.secretManager(ctx, r.Log)
2432+
validator := NewImageAuthValidator(r.Recorder)
2433+
return validator.Validate(ctx, host, secretManager)
2434+
}
2435+
24102436
func credentialsFromSecret(bmcCredsSecret *corev1.Secret) *bmc.Credentials {
24112437
// We trim surrounding whitespace because those characters are
24122438
// unlikely to be part of the username or password and it is
@@ -2492,6 +2518,8 @@ func (r *BareMetalHostReconciler) updateEventHandler(e event.UpdateEvent) bool {
24922518

24932519
// SetupWithManager registers the reconciler to be run by the manager.
24942520
func (r *BareMetalHostReconciler) SetupWithManager(mgr ctrl.Manager, preprovImgEnable bool, maxConcurrentReconcile int) error {
2521+
r.Recorder = mgr.GetEventRecorderFor("baremetalhost-controller")
2522+
24952523
controller := ctrl.NewControllerManagedBy(mgr).
24962524
For(&metal3api.BareMetalHost{}).
24972525
WithEventFilter(
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package controllers
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
metal3api "github.com/metal3-io/baremetal-operator/apis/metal3.io/v1alpha1"
8+
"github.com/metal3-io/baremetal-operator/pkg/secretutils"
9+
corev1 "k8s.io/api/core/v1"
10+
k8serrors "k8s.io/apimachinery/pkg/api/errors"
11+
"k8s.io/apimachinery/pkg/types"
12+
"k8s.io/client-go/tools/record"
13+
)
14+
15+
const (
16+
// Events.
17+
EventAuthSecretIrrelevant = "ImageAuthIrrelevant"
18+
EventAuthFormatUnsupported = "ImageAuthFormatUnsupported"
19+
EventAuthParseError = "ImageAuthParseError"
20+
)
21+
22+
// ImageAuthValidator validates image authentication secrets.
23+
type ImageAuthValidator struct {
24+
recorder record.EventRecorder
25+
}
26+
27+
// NewImageAuthValidator creates a new ImageAuthValidator.
28+
func NewImageAuthValidator(recorder record.EventRecorder) *ImageAuthValidator {
29+
return &ImageAuthValidator{recorder: recorder}
30+
}
31+
32+
// Validate validates the image authentication secret for the given BMH and
33+
// returns the base64-encoded credentials in the format expected by Ironic.
34+
// Callers must ensure bmh.Spec.Image and OCIAuthSecretName are non-nil
35+
// (getImageAuthSecret already performs these checks).
36+
func (v *ImageAuthValidator) Validate(ctx context.Context, bmh *metal3api.BareMetalHost, secretMgr secretutils.SecretManager) (string, error) {
37+
img := bmh.Spec.Image
38+
ociRelevant := img.IsOCI()
39+
secretName := *img.OCIAuthSecretName
40+
41+
if !ociRelevant {
42+
if v.recorder != nil {
43+
v.recorder.Eventf(bmh, corev1.EventTypeWarning, EventAuthSecretIrrelevant,
44+
"ociAuthSecretName=%q is set but image URL is not oci:// (%s)", secretName, img.URL)
45+
}
46+
return "", nil
47+
}
48+
49+
key := types.NamespacedName{Namespace: bmh.Namespace, Name: secretName}
50+
sec, err := secretMgr.ObtainSecret(ctx, key)
51+
if err != nil {
52+
if k8serrors.IsNotFound(err) {
53+
return "", fmt.Errorf("auth secret %q not found in namespace %q", secretName, bmh.Namespace)
54+
}
55+
return "", err
56+
}
57+
58+
if sec.Type != corev1.SecretTypeDockerConfigJson && sec.Type != corev1.SecretTypeDockercfg {
59+
if v.recorder != nil {
60+
v.recorder.Eventf(bmh, corev1.EventTypeWarning, EventAuthFormatUnsupported,
61+
"Secret %q has unsupported type %q", secretName, sec.Type)
62+
}
63+
return "", fmt.Errorf("secret %q has unsupported type %q (expected %s or %s)",
64+
secretName, sec.Type, corev1.SecretTypeDockerConfigJson, corev1.SecretTypeDockercfg)
65+
}
66+
67+
credentials, err := secretutils.ExtractRegistryCredentials(sec, img.URL)
68+
if err != nil {
69+
if v.recorder != nil {
70+
v.recorder.Eventf(bmh, corev1.EventTypeWarning, EventAuthParseError,
71+
"Failed to extract credentials from secret %q: %v", secretName, err)
72+
}
73+
return "", fmt.Errorf("failed to extract credentials from secret %q: %w", secretName, err)
74+
}
75+
76+
return credentials, nil
77+
}

pkg/provisioner/ironic/clients/updateopts.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ func sanitisedValue(data any) any {
8484

8585
for _, k := range value.MapKeys() {
8686
safeDatumValue := value.MapIndex(k)
87-
if strings.Contains(k.String(), "password") {
87+
if strings.Contains(k.String(), "password") || strings.Contains(k.String(), "secret") {
8888
safeDatumValue = reflect.ValueOf("<redacted>")
8989
}
9090
safeValue.SetMapIndex(k, safeDatumValue)
@@ -93,19 +93,31 @@ func sanitisedValue(data any) any {
9393
return safeValue.Interface()
9494
}
9595

96+
func isSensitiveOption(name string) bool {
97+
return strings.Contains(name, "password") || strings.Contains(name, "secret")
98+
}
99+
96100
func getUpdateOperation(name string, currentData map[string]any, desiredValue any, path string, log logr.Logger) *nodes.UpdateOperation {
97101
current, present := currentData[name]
98102

99103
desiredValue = deref(desiredValue)
100104
if desiredValue != nil {
101105
if !(present && optionValueEqual(deref(current), desiredValue)) {
106+
logValue := sanitisedValue(desiredValue)
107+
if isSensitiveOption(name) {
108+
logValue = "<redacted>"
109+
}
102110
if present {
111+
oldLogValue := sanitisedValue(current)
112+
if isSensitiveOption(name) {
113+
oldLogValue = "<redacted>"
114+
}
103115
log.Info("updating option data",
104-
"value", sanitisedValue(desiredValue),
105-
"oldValue", current)
116+
"value", logValue,
117+
"oldValue", oldLogValue)
106118
} else {
107119
log.Info("adding option data",
108-
"value", sanitisedValue(desiredValue))
120+
"value", logValue)
109121
}
110122
return &nodes.UpdateOperation{
111123
Op: nodes.AddOp, // Add also does replace

0 commit comments

Comments
 (0)