diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index 102a921..d99f5c6 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -4,13 +4,21 @@ 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/bootc" + "github.com/jlebon/bootc-operator/internal/daemon" ) var ( @@ -20,6 +28,7 @@ var ( func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(bootcv1alpha1.AddToScheme(scheme)) } func main() { @@ -31,15 +40,39 @@ 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, + Executor: bootc.NewHostExecutor(), + }).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) 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 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 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 +} 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 +} 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/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.go b/internal/daemon/reconciler.go new file mode 100644 index 0000000..084a329 --- /dev/null +++ b/internal/daemon/reconciler.go @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: Apache-2.0 + +package daemon + +import ( + "context" + "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 +// 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 + Executor bootc.Executor +} + +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) + } + + 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 +} 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) +} diff --git a/test/e2e/controller_test.go b/test/e2e/bootcnode_test.go similarity index 72% rename from test/e2e/controller_test.go rename to test/e2e/bootcnode_test.go index bc8ad9f..1e7850c 100644 --- a/test/e2e/controller_test.go +++ b/test/e2e/bootcnode_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()) @@ -60,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, @@ -71,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()) } 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)