From 32b9359c9df53469ec185c1347f630abe9cb3998 Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Fri, 11 Sep 2026 12:10:37 +0000 Subject: [PATCH 1/6] api: remove pullSecretHash from BootcNodeSpec The daemon fetches the pull secret from the Kubernetes API on every staging reconcile, so there is no need for a hash-based change detection field in the spec. If credentials change mid-pull, the next reconciliation will pick up the updated secret automatically. The implementation will follow in the next commits. Signed-off-by: Alice Frosi --- api/v1alpha1/bootcnode_types.go | 6 ------ config/crd/bases/node.bootc.dev_bootcnodes.yaml | 6 ------ 2 files changed, 12 deletions(-) diff --git a/api/v1alpha1/bootcnode_types.go b/api/v1alpha1/bootcnode_types.go index 1e26e44..381bf55 100644 --- a/api/v1alpha1/bootcnode_types.go +++ b/api/v1alpha1/bootcnode_types.go @@ -118,12 +118,6 @@ type BootcNodeSpec struct { // credentials. Copied from the owning pool's spec. // +optional PullSecretRef *PullSecretRef `json:"pullSecretRef,omitempty"` - - // pullSecretHash is a hash of the pull secret's content, used to - // detect changes. When this value changes, the daemon re-fetches - // the secret and updates the host filesystem. - // +optional - PullSecretHash string `json:"pullSecretHash,omitempty"` } // BootcNodeStatus defines the observed state of a BootcNode. diff --git a/config/crd/bases/node.bootc.dev_bootcnodes.yaml b/config/crd/bases/node.bootc.dev_bootcnodes.yaml index a769bf1..32ff4b6 100644 --- a/config/crd/bases/node.bootc.dev_bootcnodes.yaml +++ b/config/crd/bases/node.bootc.dev_bootcnodes.yaml @@ -62,12 +62,6 @@ spec: - Staged - Booted type: string - pullSecretHash: - description: |- - pullSecretHash is a hash of the pull secret's content, used to - detect changes. When this value changes, the daemon re-fetches - the secret and updates the host filesystem. - type: string pullSecretRef: description: |- pullSecretRef references a Secret containing image pull From bd282c550ca57ffd1a4aee378f42fda2d9dc8942 Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Fri, 11 Sep 2026 11:13:18 +0000 Subject: [PATCH 2/6] controller: watch secrets and propagate pull secret ref Add a Secret watch filtered to dockerconfigjson type secrets. When a matching secret changes, all pools referencing it are re-queued. The controller fetches the secret data and passes it to the tag resolver for authenticated registry access, and propagates the pullSecretRef to each BootcNode spec. Set SecretError as a degraded condition when the referenced secret is missing. The pool recovers automatically when the secret is created. Assisted-by: AI Signed-off-by: Alice Frosi --- api/v1alpha1/bootcnodepool_types.go | 4 + config/rbac/role.yaml | 8 + .../controller/bootcnodepool_controller.go | 101 ++++++++++- internal/controller/crd_test.go | 3 +- internal/controller/pullsecret_test.go | 158 ++++++++++++++++++ internal/registry/resolver.go | 44 ++++- internal/registry/resolver_test.go | 6 +- test/util/builders.go | 5 +- 8 files changed, 310 insertions(+), 19 deletions(-) create mode 100644 internal/controller/pullsecret_test.go diff --git a/api/v1alpha1/bootcnodepool_types.go b/api/v1alpha1/bootcnodepool_types.go index f29598d..2277cbb 100644 --- a/api/v1alpha1/bootcnodepool_types.go +++ b/api/v1alpha1/bootcnodepool_types.go @@ -56,6 +56,10 @@ const ( // resolve the tag. PoolTagResolutionError string = "TagResolutionError" + // PoolSecretError means the referenced pull secret is missing or + // does not contain the expected key. + PoolSecretError string = "SecretError" + // PoolHealthy means no issues. PoolHealthy string = "Healthy" ) diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index c3d9423..2bc9d0d 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -34,6 +34,14 @@ rules: - pods/eviction verbs: - create +- apiGroups: + - "" + resources: + - secrets + verbs: + - get + - list + - watch - apiGroups: - apps resources: diff --git a/internal/controller/bootcnodepool_controller.go b/internal/controller/bootcnodepool_controller.go index 966f6d5..3affcb8 100644 --- a/internal/controller/bootcnodepool_controller.go +++ b/internal/controller/bootcnodepool_controller.go @@ -48,7 +48,7 @@ type drainStatus struct { // TagResolver resolves a container image reference to a digest. type TagResolver interface { - Resolve(ctx context.Context, ref string) (string, error) + Resolve(ctx context.Context, ref string, auth []byte) (string, error) } // BootcNodePoolReconciler reconciles a BootcNodePool object @@ -79,6 +79,7 @@ type BootcNodePoolReconciler struct { // +kubebuilder:rbac:groups=node.bootc.dev,resources=bootcnodes,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=node.bootc.dev,resources=bootcnodes/status,verbs=get // +kubebuilder:rbac:groups="",resources=nodes,verbs=get;list;watch;update;patch +// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch // +kubebuilder:rbac:groups="",resources=pods,verbs=get;list // +kubebuilder:rbac:groups="",resources=pods/eviction,verbs=create // +kubebuilder:rbac:groups=apps,resources=daemonsets,verbs=get @@ -94,6 +95,7 @@ func (r *BootcNodePoolReconciler) SetupWithManager(mgr ctrl.Manager) error { For(&bootcv1alpha1.BootcNodePool{}). Owns(&bootcv1alpha1.BootcNode{}). Watches(&corev1.Node{}, handler.EnqueueRequestsFromMapFunc(r.mapNodeToPoolRequests), builder.WithPredicates(nodePredicates())). + Watches(&corev1.Secret{}, handler.EnqueueRequestsFromMapFunc(r.mapSecretToPoolRequests), builder.WithPredicates(secretTypePredicate())). WatchesRawSource(source.Channel(r.drainCh, &handler.EnqueueRequestForObject{})). Named("bootcnodepool"). Complete(r) @@ -161,6 +163,39 @@ func (r *BootcNodePoolReconciler) mapNodeToPoolRequests( return requests } +// mapSecretToPoolRequests maps a Secret event to the BootcNodePool(s) that +// should be reconciled. It enqueues pools whose pullSecretRef matches the +// changed Secret's name and namespace. +func (r *BootcNodePoolReconciler) mapSecretToPoolRequests( + ctx context.Context, + obj client.Object, +) []reconcile.Request { + secret, ok := obj.(*corev1.Secret) + if !ok { + return nil + } + + var pools bootcv1alpha1.BootcNodePoolList + if err := r.List(ctx, &pools); err != nil { + log := logf.FromContext(ctx) + log.Error(err, "Failed to list BootcNodePools in secret mapper") + return nil + } + + var requests []reconcile.Request + for i := range pools.Items { + pool := &pools.Items[i] + if pool.Spec.PullSecretRef != nil && + pool.Spec.PullSecretRef.Name == secret.Name && + pool.Spec.PullSecretRef.Namespace == secret.Namespace { + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{Name: pool.Name}, + }) + } + } + return requests +} + // nodeSelectorMatchesNode evaluates whether a node's labels match a // LabelSelector. func nodeSelectorMatchesNode(sel *metav1.LabelSelector, node *corev1.Node) (bool, error) { @@ -219,6 +254,18 @@ func nodeUnschedulableChanged(oldNode, newNode *corev1.Node) bool { return oldNode.Spec.Unschedulable != newNode.Spec.Unschedulable } +// secretTypePredicate returns a predicate that accepts only Secrets of +// type kubernetes.io/dockerconfigjson. +func secretTypePredicate() predicate.Predicate { + return predicate.NewPredicateFuncs(func(obj client.Object) bool { + secret, ok := obj.(*corev1.Secret) + if !ok { + return false + } + return secret.Type == corev1.SecretTypeDockerConfigJson + }) +} + // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. func (r *BootcNodePoolReconciler) Reconcile( @@ -269,8 +316,12 @@ func (r *BootcNodePoolReconciler) Reconcile( // Status writes to `pool` are also permitted; we Update() back once at // the end. - // Resolve the target digest from the image ref. - resolveResult, err := r.resolveTargetDigest(ctx, &pool) + secretData, err := r.fetchPullSecretData(ctx, &pool) + if err != nil { + log.Error(err, "Failed to fetch pull secret") + setPoolDegraded(&pool, bootcv1alpha1.PoolSecretError, err.Error()) + } + resolveResult, err := r.resolveTargetDigest(ctx, &pool, secretData) if err != nil { if isInvalidSpecError(err) { return r.setInvalidSpecCondition(ctx, &pool, err) @@ -387,6 +438,7 @@ func (r *BootcNodePoolReconciler) handlePoolDeletion( func (r *BootcNodePoolReconciler) resolveTargetDigest( ctx context.Context, pool *bootcv1alpha1.BootcNodePool, + secretData []byte, ) (ctrl.Result, error) { log := logf.FromContext(ctx) @@ -415,7 +467,7 @@ func (r *BootcNodePoolReconciler) resolveTargetDigest( return ctrl.Result{RequeueAfter: remaining}, nil } - digest, err := r.TagResolver.Resolve(ctx, pool.Spec.Image.Ref) + digest, err := r.TagResolver.Resolve(ctx, pool.Spec.Image.Ref, secretData) if err != nil { log.Error(err, "Failed to resolve tag", "ref", pool.Spec.Image.Ref) setPoolDegraded(pool, bootcv1alpha1.PoolTagResolutionError, err.Error()) @@ -600,6 +652,38 @@ func (r *BootcNodePoolReconciler) listAllBootcNodes( return all, nil } +// fetchPullSecretData fetches the .dockerconfigjson data from the pull +// secret referenced by the pool. Returns nil if no pull secret is set. +func (r *BootcNodePoolReconciler) fetchPullSecretData( + ctx context.Context, + pool *bootcv1alpha1.BootcNodePool, +) ([]byte, error) { + if pool.Spec.PullSecretRef == nil { + return nil, nil + } + + key := types.NamespacedName{ + Name: pool.Spec.PullSecretRef.Name, + Namespace: pool.Spec.PullSecretRef.Namespace, + } + var secret corev1.Secret + if err := r.Get(ctx, key, &secret); err != nil { + return nil, fmt.Errorf("fetching pull secret %s/%s: %w", key.Namespace, key.Name, err) + } + + data, ok := secret.Data[corev1.DockerConfigJsonKey] + if !ok { + return nil, fmt.Errorf( + "pull secret %s/%s missing %s key", + key.Namespace, + key.Name, + corev1.DockerConfigJsonKey, + ) + } + + return data, nil +} + // syncBootcNodeSpec updates a BootcNode's spec fields to match the pool. func (r *BootcNodePoolReconciler) syncBootcNodeSpec( ctx context.Context, @@ -786,10 +870,11 @@ func (r *BootcNodePoolReconciler) restoreCordonState( var poolDegradedPrecedence = map[string]int{ bootcv1alpha1.PoolHealthy: 0, bootcv1alpha1.PoolNodeDegraded: 1, - bootcv1alpha1.PoolNodeConflict: 2, - bootcv1alpha1.PoolRolloutHalted: 3, - bootcv1alpha1.PoolInvalidSpec: 4, - bootcv1alpha1.PoolTagResolutionError: 5, + bootcv1alpha1.PoolSecretError: 2, + bootcv1alpha1.PoolNodeConflict: 3, + bootcv1alpha1.PoolRolloutHalted: 4, + bootcv1alpha1.PoolInvalidSpec: 5, + bootcv1alpha1.PoolTagResolutionError: 6, } // setPoolDegraded sets the Degraded condition on the pool, but only if the new diff --git a/internal/controller/crd_test.go b/internal/controller/crd_test.go index 595767a..7c20b6a 100644 --- a/internal/controller/crd_test.go +++ b/internal/controller/crd_test.go @@ -27,7 +27,6 @@ const ( testSecretName = "my-pull-secret" testSecretNS = "bootc-operator" - testSecretHash = "sha256:b37e50cedcd3e3f1ff64f4afc0422084ae694253cf399326868e07a35f4a45fb" // "secret" ) func TestBootcNodePoolCRD(t *testing.T) { @@ -63,7 +62,7 @@ func TestBootcNodeCRD(t *testing.T) { ctx := context.Background() node := testutil.NewNode("worker-1", testImageDigestRefA, - testutil.WithNodePullSecret(testSecretName, testSecretNS, testSecretHash), + testutil.WithNodePullSecret(testSecretName, testSecretNS), ) // Save the spec before Create, which mutates node in-place. diff --git a/internal/controller/pullsecret_test.go b/internal/controller/pullsecret_test.go new file mode 100644 index 0000000..7d089d5 --- /dev/null +++ b/internal/controller/pullsecret_test.go @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + "testing" + + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + bootcv1alpha1 "github.com/bootc-dev/bootc-operator/api/v1alpha1" + testutil "github.com/bootc-dev/bootc-operator/test/util" +) + +func newDockerConfigSecret(name, namespace string, dockerCfg []byte) *corev1.Secret { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Type: corev1.SecretTypeDockerConfigJson, + Data: map[string][]byte{ + corev1.DockerConfigJsonKey: dockerCfg, + }, + } +} + +func TestPullSecretRefPropagation(t *testing.T) { + g := NewWithT(t) + g.SetDefaultEventuallyTimeout(pollTimeout) + g.SetDefaultEventuallyPollingInterval(pollInterval) + ctx := context.Background() + + secretData := []byte(`{"auths":{"registry.example.com":{}}}`) + + secret := newDockerConfigSecret("ps-prop-secret", "default", secretData) + g.Expect(k8sClient.Create(ctx, secret)).To(Succeed()) + t.Cleanup(func() { _ = k8sClient.Delete(ctx, secret) }) + + node := testutil.NewK8sNode("ps-prop-node", testutil.WorkerLabels()) + g.Expect(k8sClient.Create(ctx, node)).To(Succeed()) + t.Cleanup(func() { _ = k8sClient.Delete(ctx, node) }) + + pool := testutil.NewPool("ps-prop-pool", testImageDigestRefA, + testutil.WithWorkerSelector(), + testutil.WithPullSecret("ps-prop-secret", "default"), + ) + g.Expect(k8sClient.Create(ctx, pool)).To(Succeed()) + t.Cleanup(func() { _ = k8sClient.Delete(ctx, pool) }) + + g.Eventually(func() (*bootcv1alpha1.PullSecretRef, error) { + var bn bootcv1alpha1.BootcNode + err := k8sClient.Get(ctx, client.ObjectKey{Name: "ps-prop-node"}, &bn) + return bn.Spec.PullSecretRef, err + }).Should(Equal(&bootcv1alpha1.PullSecretRef{ + Name: "ps-prop-secret", Namespace: "default", + })) +} + +func TestPullSecretMissingDegradedAndRecovery(t *testing.T) { + g := NewWithT(t) + g.SetDefaultEventuallyTimeout(pollTimeout) + g.SetDefaultEventuallyPollingInterval(pollInterval) + ctx := context.Background() + + node := testutil.NewK8sNode("ps-missing-node", testutil.WorkerLabels()) + g.Expect(k8sClient.Create(ctx, node)).To(Succeed()) + t.Cleanup(func() { _ = k8sClient.Delete(ctx, node) }) + + pool := testutil.NewPool("ps-missing-pool", testImageDigestRefA, + testutil.WithWorkerSelector(), + testutil.WithPullSecret("ps-missing-secret", "default"), + ) + g.Expect(k8sClient.Create(ctx, pool)).To(Succeed()) + t.Cleanup(func() { _ = k8sClient.Delete(ctx, pool) }) + + // Pool should be Degraded/SecretError. + g.Eventually(func() ([]metav1.Condition, error) { + var p bootcv1alpha1.BootcNodePool + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(pool), &p) + return p.Status.Conditions, err + }).Should(ContainElement(And( + HaveField("Type", bootcv1alpha1.PoolDegraded), + HaveField("Status", metav1.ConditionTrue), + HaveField("Reason", bootcv1alpha1.PoolSecretError), + ))) + + // BootcNode should still be created (degrade but continue rollout). + g.Eventually(func() error { + return k8sClient.Get( + ctx, + client.ObjectKey{Name: "ps-missing-node"}, + &bootcv1alpha1.BootcNode{}, + ) + }).Should(Succeed()) + + // Create the missing secret and verify recovery. + secretData := []byte(`{"auths":{"recovered":{}}}`) + secret := newDockerConfigSecret("ps-missing-secret", "default", secretData) + g.Expect(k8sClient.Create(ctx, secret)).To(Succeed()) + t.Cleanup(func() { _ = k8sClient.Delete(ctx, secret) }) + + g.Eventually(func() ([]metav1.Condition, error) { + var p bootcv1alpha1.BootcNodePool + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(pool), &p) + return p.Status.Conditions, err + }).Should(ContainElement(And( + HaveField("Type", bootcv1alpha1.PoolDegraded), + HaveField("Status", metav1.ConditionFalse), + HaveField("Reason", bootcv1alpha1.PoolHealthy), + ))) +} + +func TestPullSecretRemovalClearsRef(t *testing.T) { + g := NewWithT(t) + g.SetDefaultEventuallyTimeout(pollTimeout) + g.SetDefaultEventuallyPollingInterval(pollInterval) + ctx := context.Background() + + secretData := []byte(`{"auths":{"remove":{}}}`) + secret := newDockerConfigSecret("ps-remove-secret", "default", secretData) + g.Expect(k8sClient.Create(ctx, secret)).To(Succeed()) + t.Cleanup(func() { _ = k8sClient.Delete(ctx, secret) }) + + node := testutil.NewK8sNode("ps-remove-node", testutil.WorkerLabels()) + g.Expect(k8sClient.Create(ctx, node)).To(Succeed()) + t.Cleanup(func() { _ = k8sClient.Delete(ctx, node) }) + + pool := testutil.NewPool("ps-remove-pool", testImageDigestRefA, + testutil.WithWorkerSelector(), + testutil.WithPullSecret("ps-remove-secret", "default"), + ) + g.Expect(k8sClient.Create(ctx, pool)).To(Succeed()) + t.Cleanup(func() { _ = k8sClient.Delete(ctx, pool) }) + + // Wait for pullSecretRef to be set on BootcNode. + g.Eventually(func() (*bootcv1alpha1.PullSecretRef, error) { + var bn bootcv1alpha1.BootcNode + err := k8sClient.Get(ctx, client.ObjectKey{Name: "ps-remove-node"}, &bn) + return bn.Spec.PullSecretRef, err + }).ShouldNot(BeNil()) + + // Remove pullSecretRef from pool. + var freshPool bootcv1alpha1.BootcNodePool + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(pool), &freshPool)).To(Succeed()) + freshPool.Spec.PullSecretRef = nil + g.Expect(k8sClient.Update(ctx, &freshPool)).To(Succeed()) + + // Verify pullSecretRef is cleared on BootcNode. + g.Eventually(func() (*bootcv1alpha1.PullSecretRef, error) { + var bn bootcv1alpha1.BootcNode + err := k8sClient.Get(ctx, client.ObjectKey{Name: "ps-remove-node"}, &bn) + return bn.Spec.PullSecretRef, err + }).Should(BeNil()) +} diff --git a/internal/registry/resolver.go b/internal/registry/resolver.go index 649507b..c954053 100644 --- a/internal/registry/resolver.go +++ b/internal/registry/resolver.go @@ -3,10 +3,12 @@ package registry import ( "context" "crypto/tls" + "encoding/json" "errors" "fmt" "net/http" + "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/v1/remote" ) @@ -18,7 +20,7 @@ type GGCRResolver struct { AllowInsecure bool } -func (r *GGCRResolver) Resolve(ctx context.Context, ref string) (string, error) { +func (r *GGCRResolver) Resolve(ctx context.Context, ref string, auth []byte) (string, error) { parsed, err := name.ParseReference(ref) if err != nil { return "", fmt.Errorf("parsing reference %q: %w", ref, err) @@ -26,17 +28,53 @@ func (r *GGCRResolver) Resolve(ctx context.Context, ref string) (string, error) if d, ok := parsed.(name.Digest); ok { return d.DigestStr(), nil } - desc, err := remote.Get(parsed, remote.WithContext(ctx)) + + opts := []remote.Option{remote.WithContext(ctx)} + if len(auth) > 0 { + kc, err := keychainFromDockerConfig(auth) + if err != nil { + return "", fmt.Errorf("parsing auth for %q: %w", ref, err) + } + opts = append(opts, remote.WithAuthFromKeychain(kc)) + } + + desc, err := remote.Get(parsed, opts...) var tlsErr *tls.CertificateVerificationError if (errors.As(err, &tlsErr) || errors.Is(err, http.ErrSchemeMismatch)) && r.AllowInsecure { insecure, parseErr := name.ParseReference(ref, name.Insecure) if parseErr != nil { return "", fmt.Errorf("parsing reference %q: %w", ref, parseErr) } - desc, err = remote.Get(insecure, remote.WithContext(ctx)) + desc, err = remote.Get(insecure, opts...) } if err != nil { return "", fmt.Errorf("fetching manifest for %q: %w", ref, err) } return desc.Digest.String(), nil } + +// dockerConfigJSON mirrors the structure of a Kubernetes +// kubernetes.io/dockerconfigjson secret's .dockerconfigjson key. +type dockerConfigJSON struct { + Auths map[string]authn.AuthConfig `json:"auths"` +} + +// staticKeychain resolves credentials from a parsed dockerconfigjson. +type staticKeychain struct { + auths map[string]authn.AuthConfig +} + +func (k *staticKeychain) Resolve(target authn.Resource) (authn.Authenticator, error) { + if cfg, ok := k.auths[target.RegistryStr()]; ok { + return authn.FromConfig(cfg), nil + } + return authn.Anonymous, nil +} + +func keychainFromDockerConfig(data []byte) (authn.Keychain, error) { + var cfg dockerConfigJSON + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("unmarshaling docker config: %w", err) + } + return &staticKeychain{auths: cfg.Auths}, nil +} diff --git a/internal/registry/resolver_test.go b/internal/registry/resolver_test.go index 10a044e..a060e40 100644 --- a/internal/registry/resolver_test.go +++ b/internal/registry/resolver_test.go @@ -48,7 +48,7 @@ func TestResolveValidTag(t *testing.T) { g.Expect(err).NotTo(HaveOccurred()) resolver := &GGCRResolver{} - got, err := resolver.Resolve(context.Background(), ref.String()) + got, err := resolver.Resolve(context.Background(), ref.String(), nil) g.Expect(err).NotTo(HaveOccurred()) g.Expect(got).To(Equal(want.String())) }) @@ -61,7 +61,7 @@ func TestResolveDigestRef(t *testing.T) { resolver := &GGCRResolver{} digest := "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" ref := "registry.example.com/test/image@" + digest - got, err := resolver.Resolve(context.Background(), ref) + got, err := resolver.Resolve(context.Background(), ref, nil) g.Expect(err).NotTo(HaveOccurred()) g.Expect(got).To(Equal(digest)) } @@ -70,7 +70,7 @@ func TestResolveUnreachableRegistry(t *testing.T) { g := NewWithT(t) resolver := &GGCRResolver{} - _, err := resolver.Resolve(context.Background(), "localhost:1/nonexistent/image:latest") + _, err := resolver.Resolve(context.Background(), "localhost:1/nonexistent/image:latest", nil) g.Expect(err).To(HaveOccurred()) g.Expect(err.Error()).To(ContainSubstring("fetching manifest")) } diff --git a/test/util/builders.go b/test/util/builders.go index 8199e4c..318954a 100644 --- a/test/util/builders.go +++ b/test/util/builders.go @@ -231,14 +231,13 @@ func WithDesiredImageState(state bootcv1alpha1.DesiredImageState) NodeOption { } } -// WithNodePullSecret sets the pull secret reference and hash on a node. -func WithNodePullSecret(name, namespace, hash string) NodeOption { +// WithNodePullSecret sets the pull secret reference on a node. +func WithNodePullSecret(name, namespace string) NodeOption { return func(node *bootcv1alpha1.BootcNode) { node.Spec.PullSecretRef = &bootcv1alpha1.PullSecretRef{ Name: name, Namespace: namespace, } - node.Spec.PullSecretHash = hash } } From 1b81c41770d8c4c6fd7dbfd203cc6b9c67616b0f Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Fri, 11 Sep 2026 11:49:05 +0000 Subject: [PATCH 3/6] daemon: fetch pull secret and write host auth file When a BootcNode has pullSecretRef set, the daemon fetches the secret via a direct API GET and writes the .dockerconfigjson data to /run/ostree/auth.json on the host filesystem. The write is atomic (temp file + rename) and happens on every reconcile where an update is in progress, before classifyAction decides whether to stage. On failure the daemon logs a warning and continues since the registry may be public. Also adds get on Secrets to the daemon ClusterRole. Assisted-by: AI Signed-off-by: Alice Frosi --- cmd/daemon/main.go | 1 + config/rbac/daemon_role.yaml | 6 +++ internal/daemon/reconciler.go | 76 +++++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+) diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index 84ac526..ad1a77e 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -94,6 +94,7 @@ func main() { Client: mgr.GetClient(), Scheme: mgr.GetScheme(), NodeName: nodeName, + HostRoot: "/proc/1/root", Executor: executor, StatusWatcher: watcher, }).SetupWithManager(mgr); err != nil { diff --git a/config/rbac/daemon_role.yaml b/config/rbac/daemon_role.yaml index 7e87c36..f026a8e 100644 --- a/config/rbac/daemon_role.yaml +++ b/config/rbac/daemon_role.yaml @@ -6,6 +6,12 @@ metadata: app.kubernetes.io/managed-by: kustomize name: daemon-role rules: +- apiGroups: + - "" + resources: + - secrets + verbs: + - get - apiGroups: - node.bootc.dev resources: diff --git a/internal/daemon/reconciler.go b/internal/daemon/reconciler.go index 901445a..0fb6467 100644 --- a/internal/daemon/reconciler.go +++ b/internal/daemon/reconciler.go @@ -5,6 +5,8 @@ package daemon import ( "context" "fmt" + "os" + "path/filepath" "reflect" "sync" "time" @@ -12,10 +14,12 @@ import ( "github.com/distribution/reference" "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" @@ -55,6 +59,7 @@ type BootcNodeReconciler struct { client.Client Scheme *runtime.Scheme NodeName string + HostRoot string Executor bootc.Executor StatusWatcher *StatusWatcher @@ -206,6 +211,8 @@ func (r *BootcNodeReconciler) reconcileBootcNode( }, nil } + r.syncPullSecret(ctx, log, bn) + desiredImage := desiredRef.String() action := r.classifyAction(bn, digested, desiredImage) @@ -380,6 +387,75 @@ func (r *BootcNodeReconciler) classifyAction( return actionReboot } +// syncPullSecret fetches the pull secret referenced by the BootcNode +// and writes it to the host filesystem so bootc can authenticate. +func (r *BootcNodeReconciler) syncPullSecret( + ctx context.Context, + log logr.Logger, + bn *bootcv1alpha1.BootcNode, +) { + if bn.Spec.PullSecretRef == nil { + return + } + + key := types.NamespacedName{ + Name: bn.Spec.PullSecretRef.Name, + Namespace: bn.Spec.PullSecretRef.Namespace, + } + var secret corev1.Secret + if err := r.Get(ctx, key, &secret); err != nil { + log.Error(err, "Failed to fetch pull secret, continuing", "secret", key) + return + } + + data, ok := secret.Data[corev1.DockerConfigJsonKey] + if !ok { + log.Info("Pull secret missing .dockerconfigjson key, continuing", "secret", key) + return + } + + if err := r.writeAuthFile(data); err != nil { + log.Error(err, "Failed to write auth file, continuing") + } +} + +// writeAuthFile writes dockerconfigjson data to the host filesystem +// atomically (temp file + rename). +func (r *BootcNodeReconciler) writeAuthFile(data []byte) error { + hostRoot := r.HostRoot + if hostRoot == "" { + hostRoot = "/proc/1/root" + } + + dir := filepath.Join(hostRoot, "run", "ostree") + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("creating auth dir: %w", err) + } + + tmp, err := os.CreateTemp(dir, ".auth-*.json") + if err != nil { + return fmt.Errorf("creating temp file: %w", err) + } + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + _ = os.Remove(tmp.Name()) + return fmt.Errorf("writing auth data: %w", err) + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmp.Name()) + return fmt.Errorf("closing temp file: %w", err) + } + + target := filepath.Join(dir, "auth.json") + if err := os.Rename(tmp.Name(), target); err != nil { + _ = os.Remove(tmp.Name()) + return fmt.Errorf("renaming auth file: %w", err) + } + + return nil +} + func convertBootEntry(entry *bootc.BootEntry) *bootcv1alpha1.ImageInfo { if entry == nil || entry.Image == nil { return nil From 08f42d5d254b275620820f7f09b42bdc3259f79d Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Fri, 11 Sep 2026 11:51:29 +0000 Subject: [PATCH 4/6] Makefile: enable auth registry for e2e pull secret tests Add --registry-user and --registry-password to the bink cluster start command. This starts an additional authenticated registry on port 5001 alongside the existing unauthenticated one on port 5000, so existing tests are unaffected. Assisted-by: AI Signed-off-by: Alice Frosi --- Makefile | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index d608e5d..72cbe85 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,8 @@ ARTIFACTS ?= $(abspath _output/logs) DEFAULT_KUBE_MINOR ?= 1.35 BINK_NODE_DISK_IMAGE ?= ghcr.io/bootc-dev/bink/node:v$(DEFAULT_KUBE_MINOR)-fedora-44-disk BINK_LOCAL_REGISTRY_NODE_IMAGE ?= registry.cluster.local:5000/node +E2E_REGISTRY_USER ?= e2e-user +E2E_REGISTRY_PASSWORD ?= e2e-password # YEAR defines the year value used for substituting the YEAR placeholder in the boilerplate header. YEAR ?= $(shell date +%Y) @@ -96,6 +98,7 @@ e2e: ## Run e2e tests (requires: make deploy-bink). V=1 for verbose. RUN= BINK_NODE_IMAGE_DIGEST=$$(skopeo inspect --tls-verify=false --format '{{.Digest}}' docker://localhost:5000/node:latest) \ BINK_NODE_IMAGE_UPDATE_DIGEST=$$(skopeo inspect --tls-verify=false docker://localhost:5000/node:update | jq -r '.Digest') \ BINK_NODE_IMAGE_UPDATE2_DIGEST=$$(skopeo inspect --tls-verify=false docker://localhost:5000/node:update2 | jq -r '.Digest') \ + E2E_REGISTRY_USER=$(E2E_REGISTRY_USER) E2E_REGISTRY_PASSWORD=$(E2E_REGISTRY_PASSWORD) \ go test -timeout 30m -count=1 $(if $(V),-v) $(if $(RUN),-run $(RUN)) . ##@ Build @@ -169,7 +172,8 @@ start-bink: seed-node-image ## Start a bink cluster (idempotent). bink cluster list 2>&1 | grep -qw $(BINK_CLUSTER_NAME) || { \ node_digest=$$(skopeo inspect --tls-verify=false --format '{{.Digest}}' docker://localhost:5000/node:latest) && \ bink cluster start --cluster-name $(BINK_CLUSTER_NAME) --node-name controller --api-port 0 --expose $(KUBECONFIG_BINK) \ - --node-image $(BINK_NODE_DISK_IMAGE) --target-imgref $(BINK_LOCAL_REGISTRY_NODE_IMAGE)@$$node_digest; } + --node-image $(BINK_NODE_DISK_IMAGE) --target-imgref $(BINK_LOCAL_REGISTRY_NODE_IMAGE)@$$node_digest \ + --registry-user $(E2E_REGISTRY_USER) --registry-password $(E2E_REGISTRY_PASSWORD); } kubectl --kubeconfig $(KUBECONFIG_BINK) wait --for=condition=Ready node/controller --timeout=5m .PHONY: deploy-bink From 68a674c10a096ca23d07ff81dc8cac18cf6d5e88 Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Fri, 11 Sep 2026 12:35:20 +0000 Subject: [PATCH 5/6] e2e: add TestPullSecretAuth Push the update image to the authenticated registry on port 5001, create a dockerconfigjson Secret with credentials, and verify the full staging and reboot flow through the auth-protected registry. Assisted-by: AI Signed-off-by: Alice Frosi --- test/e2e/bootcnode_test.go | 88 ++++++++++++++++++++++++++++++++++++++ test/e2e/e2eutil/env.go | 27 ++++++++++++ 2 files changed, 115 insertions(+) diff --git a/test/e2e/bootcnode_test.go b/test/e2e/bootcnode_test.go index b873be1..1466be3 100644 --- a/test/e2e/bootcnode_test.go +++ b/test/e2e/bootcnode_test.go @@ -4,6 +4,7 @@ package e2e import ( "context" + "encoding/base64" "fmt" "os" "os/exec" @@ -739,6 +740,93 @@ func TestNonExistingImage(t *testing.T) { t.Logf("Verified node %q did not stage non-existing image", nodeName) } +// TestPullSecretAuth provisions a worker node, creates a +// dockerconfigjson Secret with credentials, and verifies that the +// daemon can stage from the auth-protected registry. The auth +// registry shares storage with the unauthenticated one (port 5000), +// so the update image is already available at both endpoints. +func TestPullSecretAuth(t *testing.T) { + g := NewWithT(t) + g.SetDefaultEventuallyTimeout(pollTimeout) + g.SetDefaultEventuallyPollingInterval(pollInterval) + + env := e2eutil.New(t) + if env.RegistryUser() == "" || env.RegistryPassword() == "" { + t.Skip("E2E_REGISTRY_USER / E2E_REGISTRY_PASSWORD not set") + } + + ctx := context.Background() + nodeName := env.AddNode(t) + + // The auth registry shares storage with the unauthenticated + // registry, so the update image pushed to localhost:5000 is + // already visible at auth-registry.cluster.local:5001. + digest := env.NodeImageUpdateDigest() + + // Create a dockerconfigjson Secret with credentials for the + // in-cluster auth registry hostname. + authStr := base64.StdEncoding.EncodeToString( + []byte(env.RegistryUser() + ":" + env.RegistryPassword()), + ) + dockerCfg := fmt.Sprintf( + `{"auths":{"auth-registry.cluster.local:5001":{"auth":"%s"}}}`, + authStr, + ) + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: env.TestID() + "-pull-secret", + Namespace: testutil.OperatorNamespaceName, + }, + Type: corev1.SecretTypeDockerConfigJson, + Data: map[string][]byte{ + corev1.DockerConfigJsonKey: []byte(dockerCfg), + }, + } + g.Expect(env.Client.Create(ctx, secret)).To(Succeed()) + t.Cleanup(func() { _ = env.Client.Delete(ctx, secret) }) + + // Create a pool targeting the auth registry with the pull secret. + authImageRef := "auth-registry.cluster.local:5001/node@" + digest + pool := env.NewPool("pullsecret", authImageRef, + testutil.WithPullSecret(secret.Name, secret.Namespace), + ) + g.Expect(env.Client.Create(ctx, pool)).To(Succeed()) + + // Verify BootcNode gets the pullSecretRef. + g.Eventually(func() (*bootcv1alpha1.PullSecretRef, error) { + var bn bootcv1alpha1.BootcNode + err := env.Client.Get(ctx, client.ObjectKey{Name: nodeName}, &bn) + return bn.Spec.PullSecretRef, err + }).Should(Equal(&bootcv1alpha1.PullSecretRef{ + Name: secret.Name, Namespace: secret.Namespace, + })) + + t.Logf("BootcNode %q has pullSecretRef set", nodeName) + + // Wait for the node to stage and reboot into the update image. + g.Eventually(func() (bootcv1alpha1.BootcNodeStatus, error) { + var bn bootcv1alpha1.BootcNode + err := env.Client.Get(ctx, client.ObjectKey{Name: nodeName}, &bn) + return bn.Status, err + }).WithTimeout(5 * time.Minute).Should(And( + HaveField("Booted", And( + Not(BeNil()), + HaveField("ImageDigest", Equal(digest)), + )), + HaveField("Conditions", ContainElement(And( + HaveField("Type", bootcv1alpha1.NodeIdle), + HaveField("Status", metav1.ConditionTrue), + HaveField("Reason", bootcv1alpha1.NodeReasonIdle), + ))), + )) + + t.Logf("Node %q booted into auth-registry image", nodeName) + + // Verify pool status reflects steady state. + g.Eventually(fetchPoolStatus(ctx, env.Client, pool)). + Should(poolAllUpdated(1, digest)) +} + func fetchPoolStatus( ctx context.Context, c client.Client, diff --git a/test/e2e/e2eutil/env.go b/test/e2e/e2eutil/env.go index 6c050b1..6efebdb 100644 --- a/test/e2e/e2eutil/env.go +++ b/test/e2e/e2eutil/env.go @@ -70,6 +70,14 @@ type Env struct { // nodeImageUpdate2Digest is the manifest digest of the second update // image (e.g. "sha256:789abc..."). Used by mid-rollout image change tests. nodeImageUpdate2Digest string + + // registryUser is the username for the authenticated e2e registry + // on port 5001. Empty when not configured. + registryUser string + + // registryPassword is the password for the authenticated e2e + // registry on port 5001. Empty when not configured. + registryPassword string } // New connects to an existing bink cluster and returns an Env ready @@ -115,6 +123,8 @@ func New(t *testing.T) *Env { nodeImageRegistry: nodeImageRegistry, nodeImageUpdateDigest: nodeImageUpdateDigest, nodeImageUpdate2Digest: nodeImageUpdate2Digest, + registryUser: os.Getenv("E2E_REGISTRY_USER"), + registryPassword: os.Getenv("E2E_REGISTRY_PASSWORD"), } t.Cleanup(func() { @@ -224,6 +234,11 @@ func (e *Env) NewPool( return testutil.NewPool(e.testID+"-"+suffix, imageRef, allOpts...) } +// TestID returns the sanitized test name used for naming resources. +func (e *Env) TestID() string { + return e.testID +} + // TestLabels returns the label map identifying resources belonging to // this test. Use with testutil.WithNodeSelector() when overriding the // default node selector in NewPool. @@ -279,6 +294,18 @@ func (e *Env) NodeImageUpdate2Digest() string { return e.nodeImageUpdate2Digest } +// RegistryUser returns the authenticated registry username, or empty +// if not configured. +func (e *Env) RegistryUser() string { + return e.registryUser +} + +// RegistryPassword returns the authenticated registry password, or +// empty if not configured. +func (e *Env) RegistryPassword() string { + return e.registryPassword +} + // RetagImage reads the image at srcRef from the localhost registry and // tags it as dstTag. func RetagImage(t *testing.T, srcRef, dstTag string) { From 28592597488484821dfc97d77fc020438e82d286 Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Fri, 11 Sep 2026 14:39:14 +0200 Subject: [PATCH 6/6] gha: update bink commit Include the auth registry for bink introduced by https://github.com/bootc-dev/bink/pull/112 Signed-off-by: Alice Frosi --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 666b65b..b62d921 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -8,7 +8,7 @@ on: branches: [main] env: - BINK_COMMIT: a38101a436b96a82698363f0d07d8dba2cf088c6 + BINK_COMMIT: e7d4574fdc610a9fd9bd067aff984516ec7c507d permissions: {}