From be4a309e9de2f012f93b986bb6352b445c03d72c Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Tue, 26 May 2026 14:22:53 +0000 Subject: [PATCH 01/11] daemon: add RBAC for daemon ServiceAccount The daemon needs to be able to watch the nodes and update the their status. Assisted-by: Claude Opus 4.6 (1M context) Signed-off-by: Alice Frosi --- config/rbac/daemon_role.yaml | 24 ++++++++++++++++++++++++ config/rbac/daemon_role_binding.yaml | 15 +++++++++++++++ config/rbac/kustomization.yaml | 2 ++ 3 files changed, 41 insertions(+) create mode 100644 config/rbac/daemon_role.yaml create mode 100644 config/rbac/daemon_role_binding.yaml diff --git a/config/rbac/daemon_role.yaml b/config/rbac/daemon_role.yaml new file mode 100644 index 0000000..7e87c36 --- /dev/null +++ b/config/rbac/daemon_role.yaml @@ -0,0 +1,24 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: bootc-operator + app.kubernetes.io/managed-by: kustomize + name: daemon-role +rules: +- apiGroups: + - node.bootc.dev + resources: + - bootcnodes + verbs: + - get + - list + - watch +- apiGroups: + - node.bootc.dev + resources: + - bootcnodes/status + verbs: + - get + - update + - patch diff --git a/config/rbac/daemon_role_binding.yaml b/config/rbac/daemon_role_binding.yaml new file mode 100644 index 0000000..99cccab --- /dev/null +++ b/config/rbac/daemon_role_binding.yaml @@ -0,0 +1,15 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + labels: + app.kubernetes.io/name: bootc-operator + app.kubernetes.io/managed-by: kustomize + name: daemon-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: daemon-role +subjects: +- kind: ServiceAccount + name: daemon + namespace: system diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index 905530f..809aa02 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -5,3 +5,5 @@ resources: - leader_election_role.yaml - leader_election_role_binding.yaml - daemon_service_account.yaml +- daemon_role.yaml +- daemon_role_binding.yaml From a792882570ba9447610cb7cf14374e6f477705d0 Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Tue, 26 May 2026 14:27:35 +0000 Subject: [PATCH 02/11] daemon: add BootcNode reconciler skeleton Assisted-by: Claude Opus 4.6 (1M context) Signed-off-by: Alice Frosi --- internal/daemon/reconciler.go | 53 +++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 internal/daemon/reconciler.go diff --git a/internal/daemon/reconciler.go b/internal/daemon/reconciler.go new file mode 100644 index 0000000..8bbc0e1 --- /dev/null +++ b/internal/daemon/reconciler.go @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 + +package daemon + +import ( + "context" + "fmt" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + logf "sigs.k8s.io/controller-runtime/pkg/log" + + bootcv1alpha1 "github.com/jlebon/bootc-operator/api/v1alpha1" +) + +// BootcNodeReconciler reconciles the BootcNode for the node this daemon +// runs on. It reads bootc status from the host and writes it into the +// BootcNode's status subresource. +type BootcNodeReconciler struct { + client.Client + Scheme *runtime.Scheme + NodeName string +} + +func (r *BootcNodeReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&bootcv1alpha1.BootcNode{}). + Named("bootcnode"). + Complete(r) +} + +func (r *BootcNodeReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := logf.FromContext(ctx).WithValues("node", r.NodeName) + + if req.Name != r.NodeName { + return ctrl.Result{}, nil + } + + var bn bootcv1alpha1.BootcNode + if err := r.Get(ctx, req.NamespacedName, &bn); err != nil { + if apierrors.IsNotFound(err) { + log.Info("BootcNode not found, waiting for controller to create it") + return ctrl.Result{}, nil + } + return ctrl.Result{}, fmt.Errorf("fetching BootcNode: %w", err) + } + + log.Info("Reconciling BootcNode") + + return ctrl.Result{}, nil +} From 0583ee070df46ee9fb524db4cdfc0302cfd59b28 Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Tue, 26 May 2026 14:28:07 +0000 Subject: [PATCH 03/11] daemon: wire up manager with node registration Assisted-by: Claude Opus 4.6 (1M context) Signed-off-by: Alice Frosi --- cmd/daemon/main.go | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index 102a921..8b09aa5 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -4,13 +4,20 @@ package main import ( "flag" + "fmt" "os" + "k8s.io/apimachinery/pkg/fields" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log/zap" + + bootcv1alpha1 "github.com/jlebon/bootc-operator/api/v1alpha1" + "github.com/jlebon/bootc-operator/internal/daemon" ) var ( @@ -20,6 +27,7 @@ var ( func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(bootcv1alpha1.AddToScheme(scheme)) } func main() { @@ -31,15 +39,38 @@ func main() { ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + nodeName := os.Getenv("NODE_NAME") + if nodeName == "" { + setupLog.Error(fmt.Errorf("NODE_NAME not set"), "NODE_NAME environment variable is required") + os.Exit(1) + } + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ Scheme: scheme, + // Only cache the BootcNode object for this node to avoid unnecessary watches. + Cache: cache.Options{ + ByObject: map[client.Object]cache.ByObject{ + &bootcv1alpha1.BootcNode{}: { + Field: fields.OneTermEqualSelector("metadata.name", nodeName), + }, + }, + }, }) if err != nil { setupLog.Error(err, "Failed to start manager") os.Exit(1) } - setupLog.Info("Starting daemon") + if err := (&daemon.BootcNodeReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + NodeName: nodeName, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "Failed to create controller", "controller", "bootcnode") + os.Exit(1) + } + + setupLog.Info("Starting daemon", "node", nodeName) if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { setupLog.Error(err, "Failed to run daemon") os.Exit(1) From f8d303d8aa89a21d2dd713d5aaaa3094abd3ce66 Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Tue, 26 May 2026 14:28:32 +0000 Subject: [PATCH 04/11] daemon: add nsenter executor for host command execution Uses nsenter to enter the host mount/PID namespaces and run bootc as root (--setuid/--setgid 0). The --env flag inherits PID 1's environment so bootc can detect the host's bootc state correctly. Assisted-by: Claude Opus 4.6 (1M context) Signed-off-by: Alice Frosi --- internal/bootc/executor.go | 43 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 internal/bootc/executor.go diff --git a/internal/bootc/executor.go b/internal/bootc/executor.go new file mode 100644 index 0000000..013d0ce --- /dev/null +++ b/internal/bootc/executor.go @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: Apache-2.0 + +package bootc + +import ( + "context" + "fmt" + "os/exec" +) + +// Executor abstracts the execution of bootc commands on the host. +// The real implementation uses nsenter to enter the host's mount and +// PID namespaces. Tests can provide a fake implementation. +type Executor interface { + Status(ctx context.Context) ([]byte, error) +} + +// HostExecutor runs bootc commands on the host via nsenter. +// It requires hostPID: true and privileged: true in the pod spec. +type HostExecutor struct{} + +func NewHostExecutor() *HostExecutor { + return &HostExecutor{} +} + +func (e *HostExecutor) Status(ctx context.Context) ([]byte, error) { + cmd := exec.CommandContext(ctx, + "nsenter", + "--target", "1", + "--mount", + "--pid", + "--setuid", "0", + "--setgid", "0", + "--env", + "--", + "bootc", "status", "--json", "--format-version", "1", + ) + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("running bootc status: %w", err) + } + return out, nil +} From aeb6242b8f2d779d544f5b3e885ac16d78dc320c Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Tue, 26 May 2026 14:29:10 +0000 Subject: [PATCH 05/11] daemon: add bootc status JSON types and parser Go types derived from bootc's Rust definitions in crates/lib/src/spec.rs (github.com/bootc-dev/bootc). Assisted-by: Claude Opus 4.6 (1M context) Signed-off-by: Alice Frosi --- internal/bootc/status.go | 101 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 internal/bootc/status.go diff --git a/internal/bootc/status.go b/internal/bootc/status.go new file mode 100644 index 0000000..7fe5f69 --- /dev/null +++ b/internal/bootc/status.go @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: Apache-2.0 + +package bootc + +import ( + "encoding/json" + "fmt" + "time" +) + +// Status represents the top-level bootc status --json output. +type Status struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Spec StatusSpec `json:"spec"` + Status StatusBody `json:"status"` +} + +// StatusSpec is the spec section of bootc status output. +type StatusSpec struct { + Image *ImageReference `json:"image"` + BootOrder string `json:"bootOrder"` +} + +// StatusBody is the status section of bootc status output. +type StatusBody struct { + Staged *BootEntry `json:"staged"` + Booted *BootEntry `json:"booted"` + Rollback *BootEntry `json:"rollback"` + OtherDeployments []BootEntry `json:"otherDeployments,omitempty"` + RollbackQueued bool `json:"rollbackQueued"` + Type *string `json:"type"` + UsrOverlay *FilesystemOverlay `json:"usrOverlay"` +} + +// BootEntry represents a single boot entry (booted, staged, or rollback). +type BootEntry struct { + Image *ImageStatus `json:"image"` + CachedUpdate *ImageStatus `json:"cachedUpdate"` + Incompatible bool `json:"incompatible"` + Pinned bool `json:"pinned"` + SoftRebootCapable bool `json:"softRebootCapable"` + DownloadOnly bool `json:"downloadOnly"` + Store *string `json:"store"` + Ostree *OstreeInfo `json:"ostree"` + Composefs *ComposefsInfo `json:"composefs"` +} + +// ImageStatus describes the image in a boot entry. +type ImageStatus struct { + Image ImageReference `json:"image"` + Version *string `json:"version"` + Timestamp *time.Time `json:"timestamp"` + ImageDigest string `json:"imageDigest"` + Architecture string `json:"architecture"` +} + +// ImageReference holds the transport and image pullspec. +type ImageReference struct { + Image string `json:"image"` + Transport string `json:"transport"` + Signature *ImageSignature `json:"signature,omitempty"` +} + +// OstreeInfo holds OSTree-specific metadata. +type OstreeInfo struct { + Stateroot string `json:"stateroot"` + Checksum string `json:"checksum"` + DeploySerial int `json:"deploySerial"` +} + +// ComposefsInfo holds composefs-specific metadata. +type ComposefsInfo struct { + Verity string `json:"verity"` + BootType string `json:"bootType"` + Bootloader string `json:"bootloader"` + BootDigest *string `json:"bootDigest"` + MissingVerityAllowed bool `json:"missingVerityAllowed"` +} + +// ImageSignature describes the signature verification policy. +type ImageSignature struct { + OstreeRemote *string `json:"ostreeRemote,omitempty"` + ContainerPolicy *bool `json:"containerPolicy,omitempty"` + Insecure *bool `json:"insecure,omitempty"` +} + +// FilesystemOverlay describes a /usr overlay state. +type FilesystemOverlay struct { + AccessMode string `json:"accessMode"` + Persistence string `json:"persistence"` +} + +// ParseStatus parses raw bootc status --json output. +func ParseStatus(data []byte) (*Status, error) { + var s Status + if err := json.Unmarshal(data, &s); err != nil { + return nil, fmt.Errorf("parsing bootc status JSON: %w", err) + } + return &s, nil +} From 018c0b01608dc0cb5dcb2484bda34af09a92d046 Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Tue, 26 May 2026 14:30:33 +0000 Subject: [PATCH 06/11] daemon: populate BootcNode status from bootc Assisted-by: Claude Opus 4.6 (1M context) Signed-off-by: Alice Frosi --- cmd/daemon/main.go | 2 + internal/daemon/reconciler.go | 87 ++++++++++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index 8b09aa5..d99f5c6 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -17,6 +17,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log/zap" bootcv1alpha1 "github.com/jlebon/bootc-operator/api/v1alpha1" + "github.com/jlebon/bootc-operator/internal/bootc" "github.com/jlebon/bootc-operator/internal/daemon" ) @@ -65,6 +66,7 @@ func main() { Client: mgr.GetClient(), Scheme: mgr.GetScheme(), NodeName: nodeName, + Executor: bootc.NewHostExecutor(), }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "Failed to create controller", "controller", "bootcnode") os.Exit(1) diff --git a/internal/daemon/reconciler.go b/internal/daemon/reconciler.go index 8bbc0e1..084a329 100644 --- a/internal/daemon/reconciler.go +++ b/internal/daemon/reconciler.go @@ -7,12 +7,15 @@ import ( "fmt" 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" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" logf "sigs.k8s.io/controller-runtime/pkg/log" bootcv1alpha1 "github.com/jlebon/bootc-operator/api/v1alpha1" + "github.com/jlebon/bootc-operator/internal/bootc" ) // BootcNodeReconciler reconciles the BootcNode for the node this daemon @@ -22,6 +25,7 @@ type BootcNodeReconciler struct { client.Client Scheme *runtime.Scheme NodeName string + Executor bootc.Executor } func (r *BootcNodeReconciler) SetupWithManager(mgr ctrl.Manager) error { @@ -47,7 +51,88 @@ func (r *BootcNodeReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( return ctrl.Result{}, fmt.Errorf("fetching BootcNode: %w", err) } - log.Info("Reconciling BootcNode") + patch := client.MergeFrom(bn.DeepCopy()) + if err := r.populateStatus(ctx, &bn); err != nil { + log.Error(err, "Failed to populate bootc status") + } + + if err := r.Status().Patch(ctx, &bn, patch); err != nil { + return ctrl.Result{}, fmt.Errorf("patching BootcNode status: %w", err) + } + + log.Info("Patched BootcNode status from bootc") return ctrl.Result{}, nil } + +func (r *BootcNodeReconciler) populateStatus(ctx context.Context, bn *bootcv1alpha1.BootcNode) error { + data, err := r.Executor.Status(ctx) + if err != nil { + apimeta.SetStatusCondition(&bn.Status.Conditions, metav1.Condition{ + Type: bootcv1alpha1.NodeDegraded, + Status: metav1.ConditionTrue, + Reason: bootcv1alpha1.NodeReasonError, + Message: fmt.Sprintf("failed to get bootc status: %v", err), + ObservedGeneration: bn.Generation, + }) + return fmt.Errorf("getting bootc status: %w", err) + } + + status, err := bootc.ParseStatus(data) + if err != nil { + apimeta.SetStatusCondition(&bn.Status.Conditions, metav1.Condition{ + Type: bootcv1alpha1.NodeDegraded, + Status: metav1.ConditionTrue, + Reason: bootcv1alpha1.NodeReasonError, + Message: fmt.Sprintf("failed to parse bootc status: %v", err), + ObservedGeneration: bn.Generation, + }) + return fmt.Errorf("parsing bootc status: %w", err) + } + + bn.Status.ObservedGeneration = bn.Generation + bn.Status.Booted = convertBootEntry(status.Status.Booted) + bn.Status.Staged = convertBootEntry(status.Status.Staged) + bn.Status.Rollback = convertBootEntry(status.Status.Rollback) + + apimeta.SetStatusCondition(&bn.Status.Conditions, metav1.Condition{ + Type: bootcv1alpha1.NodeIdle, + Status: metav1.ConditionTrue, + Reason: bootcv1alpha1.NodeReasonIdle, + ObservedGeneration: bn.Generation, + }) + apimeta.SetStatusCondition(&bn.Status.Conditions, metav1.Condition{ + Type: bootcv1alpha1.NodeDegraded, + Status: metav1.ConditionFalse, + Reason: bootcv1alpha1.NodeReasonHealthy, + ObservedGeneration: bn.Generation, + }) + + return nil +} + +func convertBootEntry(entry *bootc.BootEntry) *bootcv1alpha1.ImageInfo { + if entry == nil || entry.Image == nil { + return nil + } + img := entry.Image + + info := &bootcv1alpha1.ImageInfo{ + Image: img.Image.Image, + ImageDigest: img.ImageDigest, + Architecture: img.Architecture, + Incompatible: entry.Incompatible, + SoftRebootCapable: entry.SoftRebootCapable, + } + + if img.Version != nil { + info.Version = *img.Version + } + + if img.Timestamp != nil { + t := metav1.NewTime(*img.Timestamp) + info.Timestamp = &t + } + + return info +} From e44c99bde43e9686c8d53863605c1d7b778b8296 Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Wed, 27 May 2026 09:33:29 +0000 Subject: [PATCH 07/11] daemon: run as root in DaemonSet The daemon needs root to run nsenter into the host namespaces and execute bootc commands. Assisted-by: Claude Opus 4.6 (1M context) Signed-off-by: Alice Frosi --- config/daemon/daemon.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/daemon/daemon.yaml b/config/daemon/daemon.yaml index 0891bc6..03bc88e 100644 --- a/config/daemon/daemon.yaml +++ b/config/daemon/daemon.yaml @@ -33,6 +33,8 @@ spec: fieldPath: spec.nodeName securityContext: privileged: true + runAsUser: 0 + runAsGroup: 0 resources: limits: cpu: 500m From a2dce882c56a4abd995c25d07dffac6c89e6c1d2 Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Thu, 28 May 2026 08:56:02 +0000 Subject: [PATCH 08/11] test: consolidate fake image refs into shared testutil constants Assisted-by: Claude Opus 4.6 (1M context) --- internal/controller/crd_test.go | 16 +++++++--------- test/e2e/controller_test.go | 3 ++- test/e2e/crd_smoke_test.go | 8 ++++---- test/util/builders.go | 14 ++++++++++++++ 4 files changed, 27 insertions(+), 14 deletions(-) diff --git a/internal/controller/crd_test.go b/internal/controller/crd_test.go index 8c05c2c..10609bc 100644 --- a/internal/controller/crd_test.go +++ b/internal/controller/crd_test.go @@ -16,16 +16,14 @@ import ( testutil "github.com/jlebon/bootc-operator/test/util" ) -// Test constants for image refs and digests. const ( - testImageTaggedRef = "quay.io/example/myos:latest" - - testDigestA = "sha256:06f961b802bc46ee168555f066d28f4f0e9afdf3f88174c1ee6f9de004fc30a0" // "A" - testDigestB = "sha256:c0cde77fa8fef97d476c10aad3d2d54fcc2f336140d073651c2dcccf1e379fd6" // "B" - testDigestC = "sha256:12f37a8a84034d3e623d726fe10e5031f4df997ac13f4d5571b5a90c41fb84fe" // "C" - testImageDigestRefA = "quay.io/example/myos@" + testDigestA - testImageDigestRefB = "quay.io/example/myos@" + testDigestB - testImageDigestRefC = "quay.io/example/myos@" + testDigestC + testImageTaggedRef = testutil.ImageTaggedRef + testDigestA = testutil.DigestA + testDigestB = testutil.DigestB + testDigestC = testutil.DigestC + testImageDigestRefA = testutil.ImageDigestRefA + testImageDigestRefB = testutil.ImageDigestRefB + testImageDigestRefC = testutil.ImageDigestRefC testSecretName = "my-pull-secret" testSecretNS = "bootc-operator" diff --git a/test/e2e/controller_test.go b/test/e2e/controller_test.go index bc8ad9f..4ab4638 100644 --- a/test/e2e/controller_test.go +++ b/test/e2e/controller_test.go @@ -14,6 +14,7 @@ import ( bootcv1alpha1 "github.com/jlebon/bootc-operator/api/v1alpha1" "github.com/jlebon/bootc-operator/test/e2e/e2eutil" + testutil "github.com/jlebon/bootc-operator/test/util" ) const ( @@ -35,7 +36,7 @@ func TestControllerMembership(t *testing.T) { ctx := context.Background() // Create a pool selecting this test's nodes. - imageRef := "quay.io/example/myos@sha256:06f961b802bc46ee168555f066d28f4f0e9afdf3f88174c1ee6f9de004fc30a0" + imageRef := testutil.ImageDigestRefA pool := env.NewPool("workers", imageRef) g.Expect(env.Client.Create(ctx, pool)).To(Succeed()) diff --git a/test/e2e/crd_smoke_test.go b/test/e2e/crd_smoke_test.go index d14b9e2..b82d122 100644 --- a/test/e2e/crd_smoke_test.go +++ b/test/e2e/crd_smoke_test.go @@ -25,18 +25,18 @@ func TestCRDSmoke(t *testing.T) { t.Run("BootcNodePool", func(t *testing.T) { g := NewWithT(t) - pool := env.NewPool("pool", "quay.io/example/myos:latest") + pool := env.NewPool("pool", testutil.ImageTaggedRef) g.Expect(env.Client.Create(ctx, pool)).To(Succeed()) got := &bootcv1alpha1.BootcNodePool{} g.Expect(env.Client.Get(ctx, client.ObjectKeyFromObject(pool), got)).To(Succeed()) - g.Expect(got.Spec.Image.Ref).To(Equal("quay.io/example/myos:latest")) + g.Expect(got.Spec.Image.Ref).To(Equal(testutil.ImageTaggedRef)) }) t.Run("BootcNode", func(t *testing.T) { g := NewWithT(t) - node := testutil.NewNode("smoke-node", "quay.io/example/myos@sha256:abc123") + node := testutil.NewNode("smoke-node", testutil.ImageDigestRefA) g.Expect(env.Client.Create(ctx, node)).To(Succeed()) t.Cleanup(func() { _ = env.Client.Delete(ctx, node) @@ -44,7 +44,7 @@ func TestCRDSmoke(t *testing.T) { got := &bootcv1alpha1.BootcNode{} g.Expect(env.Client.Get(ctx, client.ObjectKeyFromObject(node), got)).To(Succeed()) - g.Expect(got.Spec.DesiredImage).To(Equal("quay.io/example/myos@sha256:abc123")) + g.Expect(got.Spec.DesiredImage).To(Equal(testutil.ImageDigestRefA)) g.Expect(got.Spec.DesiredImageState).To(Equal(bootcv1alpha1.DesiredImageStateStaged)) }) } diff --git a/test/util/builders.go b/test/util/builders.go index ccec261..194bf58 100644 --- a/test/util/builders.go +++ b/test/util/builders.go @@ -12,6 +12,20 @@ import ( bootcv1alpha1 "github.com/jlebon/bootc-operator/api/v1alpha1" ) +const ( + ImageRepo = "quay.io/example/myos" + + ImageTaggedRef = ImageRepo + ":latest" + + DigestA = "sha256:06f961b802bc46ee168555f066d28f4f0e9afdf3f88174c1ee6f9de004fc30a0" + DigestB = "sha256:c0cde77fa8fef97d476c10aad3d2d54fcc2f336140d073651c2dcccf1e379fd6" + DigestC = "sha256:12f37a8a84034d3e623d726fe10e5031f4df997ac13f4d5571b5a90c41fb84fe" + + ImageDigestRefA = ImageRepo + "@" + DigestA + ImageDigestRefB = ImageRepo + "@" + DigestB + ImageDigestRefC = ImageRepo + "@" + DigestC +) + // PoolOption configures a BootcNodePool. type PoolOption func(*bootcv1alpha1.BootcNodePool) From bec408b5bdcbdc59eeb135534110389e27b54784 Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Wed, 27 May 2026 12:25:45 +0000 Subject: [PATCH 09/11] daemon: add envtest unit tests for BootcNode reconciler Assisted-by: Claude Opus 4.6 (1M context) --- internal/daemon/fake_test.go | 27 +++++ internal/daemon/reconciler_test.go | 170 +++++++++++++++++++++++++++++ internal/daemon/suite_test.go | 99 +++++++++++++++++ 3 files changed, 296 insertions(+) create mode 100644 internal/daemon/fake_test.go create mode 100644 internal/daemon/reconciler_test.go create mode 100644 internal/daemon/suite_test.go diff --git a/internal/daemon/fake_test.go b/internal/daemon/fake_test.go new file mode 100644 index 0000000..b6ad398 --- /dev/null +++ b/internal/daemon/fake_test.go @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 + +package daemon + +import ( + "context" + "sync" +) + +type fakeExecutor struct { + mu sync.Mutex + data []byte + err error +} + +func (f *fakeExecutor) Status(_ context.Context) ([]byte, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.data, f.err +} + +func (f *fakeExecutor) set(data []byte, err error) { + f.mu.Lock() + defer f.mu.Unlock() + f.data = data + f.err = err +} diff --git a/internal/daemon/reconciler_test.go b/internal/daemon/reconciler_test.go new file mode 100644 index 0000000..e1fdc82 --- /dev/null +++ b/internal/daemon/reconciler_test.go @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: Apache-2.0 + +package daemon + +import ( + "context" + "fmt" + "testing" + "time" + + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + bootcv1alpha1 "github.com/jlebon/bootc-operator/api/v1alpha1" + testutil "github.com/jlebon/bootc-operator/test/util" +) + +const ( + pollInterval = 200 * time.Millisecond + pollTimeout = 10 * time.Second + + testImageRef = testutil.ImageDigestRefA + + bootcStatusFull = `{ + "apiVersion": "org.containers.bootc/v1alpha1", + "kind": "BootcHost", + "spec": { + "image": {"image": "quay.io/example/myos:latest", "transport": "registry"}, + "bootOrder": "default" + }, + "status": { + "booted": { + "image": { + "image": {"image": "quay.io/example/myos:latest", "transport": "registry"}, + "imageDigest": "` + testutil.DigestA + `", + "version": "1.0", + "architecture": "amd64" + }, + "incompatible": false, + "pinned": false, + "softRebootCapable": false, + "downloadOnly": false + }, + "staged": { + "image": { + "image": {"image": "quay.io/example/myos:latest", "transport": "registry"}, + "imageDigest": "` + testutil.DigestB + `", + "version": "2.0", + "architecture": "amd64" + }, + "incompatible": false, + "pinned": false, + "softRebootCapable": true, + "downloadOnly": false + }, + "rollback": { + "image": { + "image": {"image": "quay.io/example/myos:latest", "transport": "registry"}, + "imageDigest": "` + testutil.DigestC + `", + "version": "0.9", + "architecture": "amd64" + }, + "incompatible": false, + "pinned": false, + "softRebootCapable": false, + "downloadOnly": false + }, + "rollbackQueued": false + } +}` +) + +func TestReconcilePopulatesStatus(t *testing.T) { + g := NewWithT(t) + g.SetDefaultEventuallyTimeout(pollTimeout) + g.SetDefaultEventuallyPollingInterval(pollInterval) + ctx := context.Background() + + fake.set([]byte(bootcStatusFull), nil) + + bn := testutil.NewNode(testNodeName, testImageRef) + g.Expect(k8sClient.Create(ctx, bn)).To(Succeed()) + t.Cleanup(func() { + _ = k8sClient.Delete(ctx, bn) + }) + + g.Eventually(func(g Gomega) { + var got bootcv1alpha1.BootcNode + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(bn), &got)).To(Succeed()) + + g.Expect(got.Status.Booted).NotTo(BeNil()) + g.Expect(got.Status.Booted.Image).To(Equal(testutil.ImageTaggedRef)) + g.Expect(got.Status.Booted.ImageDigest).To(Equal(testutil.DigestA)) + g.Expect(got.Status.Booted.Version).To(Equal("1.0")) + g.Expect(got.Status.Booted.Architecture).To(Equal("amd64")) + + g.Expect(got.Status.Staged).NotTo(BeNil()) + g.Expect(got.Status.Staged.ImageDigest).To(Equal(testutil.DigestB)) + g.Expect(got.Status.Staged.Version).To(Equal("2.0")) + g.Expect(got.Status.Staged.SoftRebootCapable).To(BeTrue()) + + g.Expect(got.Status.Rollback).NotTo(BeNil()) + g.Expect(got.Status.Rollback.ImageDigest).To(Equal(testutil.DigestC)) + g.Expect(got.Status.Rollback.Version).To(Equal("0.9")) + + g.Expect(got.Status.Conditions).To(ContainElement(And( + HaveField("Type", bootcv1alpha1.NodeIdle), + HaveField("Status", metav1.ConditionTrue), + HaveField("Reason", bootcv1alpha1.NodeReasonIdle), + ))) + g.Expect(got.Status.Conditions).To(ContainElement(And( + HaveField("Type", bootcv1alpha1.NodeDegraded), + HaveField("Status", metav1.ConditionFalse), + HaveField("Reason", bootcv1alpha1.NodeReasonHealthy), + ))) + }).Should(Succeed()) +} + +func TestReconcileBootcStatusError(t *testing.T) { + g := NewWithT(t) + g.SetDefaultEventuallyTimeout(pollTimeout) + g.SetDefaultEventuallyPollingInterval(pollInterval) + ctx := context.Background() + + fake.set(nil, fmt.Errorf("bootc status failed")) + + bn := testutil.NewNode(testNodeName, testImageRef) + g.Expect(k8sClient.Create(ctx, bn)).To(Succeed()) + t.Cleanup(func() { + _ = k8sClient.Delete(ctx, bn) + }) + + g.Eventually(func(g Gomega) { + var got bootcv1alpha1.BootcNode + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(bn), &got)).To(Succeed()) + g.Expect(got.Status.Conditions).To(ContainElement(And( + HaveField("Type", bootcv1alpha1.NodeDegraded), + HaveField("Status", metav1.ConditionTrue), + HaveField("Reason", bootcv1alpha1.NodeReasonError), + HaveField("Message", ContainSubstring("bootc status")), + ))) + }).Should(Succeed()) +} + +func TestReconcileInvalidJSON(t *testing.T) { + g := NewWithT(t) + g.SetDefaultEventuallyTimeout(pollTimeout) + g.SetDefaultEventuallyPollingInterval(pollInterval) + ctx := context.Background() + + fake.set([]byte(`{invalid json`), nil) + + bn := testutil.NewNode(testNodeName, testImageRef) + g.Expect(k8sClient.Create(ctx, bn)).To(Succeed()) + t.Cleanup(func() { + _ = k8sClient.Delete(ctx, bn) + }) + + g.Eventually(func(g Gomega) { + var got bootcv1alpha1.BootcNode + g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(bn), &got)).To(Succeed()) + g.Expect(got.Status.Conditions).To(ContainElement(And( + HaveField("Type", bootcv1alpha1.NodeDegraded), + HaveField("Status", metav1.ConditionTrue), + HaveField("Reason", bootcv1alpha1.NodeReasonError), + HaveField("Message", ContainSubstring("parse")), + ))) + }).Should(Succeed()) +} diff --git a/internal/daemon/suite_test.go b/internal/daemon/suite_test.go new file mode 100644 index 0000000..9f1a094 --- /dev/null +++ b/internal/daemon/suite_test.go @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: Apache-2.0 + +package daemon + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + bootcv1alpha1 "github.com/jlebon/bootc-operator/api/v1alpha1" +) + +const testNodeName = "test-node" + +var ( + testEnv *envtest.Environment + k8sClient client.Client + fake *fakeExecutor +) + +func TestMain(m *testing.M) { + ctrl.SetLogger(zap.New(zap.UseDevMode(true))) + + if err := bootcv1alpha1.AddToScheme(scheme.Scheme); err != nil { + fmt.Fprintf(os.Stderr, "Failed to add scheme: %v\n", err) + os.Exit(1) + } + + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: true, + } + + cfg, err := testEnv.Start() + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to start envtest: %v\n", err) + os.Exit(1) + } + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to create client: %v\n", err) + os.Exit(1) + } + + fake = &fakeExecutor{} + + mgr, err := ctrl.NewManager(cfg, ctrl.Options{ + Scheme: scheme.Scheme, + Metrics: metricsserver.Options{ + BindAddress: "0", + }, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to create manager: %v\n", err) + os.Exit(1) + } + + if err := (&BootcNodeReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + NodeName: testNodeName, + Executor: fake, + }).SetupWithManager(mgr); err != nil { + fmt.Fprintf(os.Stderr, "Failed to setup reconciler: %v\n", err) + os.Exit(1) + } + + mgrCtx, mgrCancel := context.WithCancel(context.Background()) + + mgrDone := make(chan struct{}) + go func() { + defer close(mgrDone) + if err := mgr.Start(mgrCtx); err != nil { + fmt.Fprintf(os.Stderr, "Manager exited with error: %v\n", err) + os.Exit(1) + } + }() + + code := m.Run() + + mgrCancel() + <-mgrDone + + if err := testEnv.Stop(); err != nil { + fmt.Fprintf(os.Stderr, "Failed to stop envtest: %v\n", err) + } + + os.Exit(code) +} From ca1870db851f6af89a9e1585841b28cc964a3fc0 Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Wed, 27 May 2026 12:35:33 +0000 Subject: [PATCH 10/11] e2e: rename controller_test.go to bootcnode_test.go Assisted-by: Claude Opus 4.6 (1M context) --- test/e2e/{controller_test.go => bootcnode_test.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename test/e2e/{controller_test.go => bootcnode_test.go} (100%) diff --git a/test/e2e/controller_test.go b/test/e2e/bootcnode_test.go similarity index 100% rename from test/e2e/controller_test.go rename to test/e2e/bootcnode_test.go From 48ee7ab713f00c627f41a93b84a538253b29b2aa Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Tue, 26 May 2026 14:31:02 +0000 Subject: [PATCH 11/11] e2e: verify daemon populates BootcNode status Assisted-by: Claude Opus 4.6 (1M context) Signed-off-by: Alice Frosi --- test/e2e/bootcnode_test.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/test/e2e/bootcnode_test.go b/test/e2e/bootcnode_test.go index 4ab4638..1e7850c 100644 --- a/test/e2e/bootcnode_test.go +++ b/test/e2e/bootcnode_test.go @@ -61,7 +61,6 @@ func TestControllerMembership(t *testing.T) { return node.Labels, err }).Should(HaveKey(bootcv1alpha1.LabelManaged)) - // Verify daemon pod schedules on the managed node and reaches Running. g.Eventually(func() ([]corev1.Pod, error) { var pods corev1.PodList err := env.Client.List(ctx, &pods, @@ -72,8 +71,20 @@ func TestControllerMembership(t *testing.T) { }, ) return pods.Items, err - }).Should(ConsistOf(And( + }).WithTimeout(3*time.Minute).Should(ConsistOf(And( HaveField("Spec.NodeName", nodeName), HaveField("Status.Phase", corev1.PodRunning), )), "expected exactly one running daemon pod on %s", nodeName) + + g.Eventually(func(g Gomega) { + g.Expect(env.Client.Get(ctx, client.ObjectKey{Name: nodeName}, &bn)).To(Succeed()) + g.Expect(bn.Status.Booted).NotTo(BeNil(), "expected booted status to be populated") + g.Expect(bn.Status.Booted.Image).NotTo(BeEmpty(), "expected booted image to be non-empty") + g.Expect(bn.Status.Booted.ImageDigest).NotTo(BeEmpty(), "expected booted imageDigest to be non-empty") + g.Expect(bn.Status.Conditions).To(ContainElement(And( + HaveField("Type", bootcv1alpha1.NodeIdle), + HaveField("Status", metav1.ConditionTrue), + HaveField("Reason", bootcv1alpha1.NodeReasonIdle), + ))) + }).WithTimeout(3 * time.Minute).Should(Succeed()) }