From 4cb6f3f36495b63ba37a270b45c01d474158c2a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20L=C3=B6nnegren?= Date: Mon, 4 May 2026 10:30:46 +0200 Subject: [PATCH] Deterministic endpoints for MachineRegistrations (#975) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds the Token field to the MachineRegistration spec. The token is used when building the registration endpoint, and if unset will default to the old behaviour of generating a random one. This is useful for keeping deterministic endpoints when restoring MachineRegistrations from backup. Signed-off-by: Fredrik Lönnegren --- .../templates/crds.yaml | 9 ++++ api/v1beta1/condition_consts.go | 3 ++ api/v1beta1/machineregistration_types.go | 8 +++ ...mental.cattle.io_machineregistrations.yaml | 9 ++++ controllers/machineregistration_controller.go | 46 ++++++++++++++-- .../machineregistration_controller_test.go | 54 +++++++++++++++++++ 6 files changed, 125 insertions(+), 4 deletions(-) diff --git a/.obs/chartfile/elemental-operator-crds-helm1.9/templates/crds.yaml b/.obs/chartfile/elemental-operator-crds-helm1.9/templates/crds.yaml index d4159d6a1..e71a5eb4c 100644 --- a/.obs/chartfile/elemental-operator-crds-helm1.9/templates/crds.yaml +++ b/.obs/chartfile/elemental-operator-crds-helm1.9/templates/crds.yaml @@ -977,6 +977,15 @@ spec: type: object machineName: type: string + token: + description: |- + Token is an optional static token for the registration URL. + When set, the registration URL will be deterministic: {server-url}/elemental/registration/{token}. + Must be URL-safe: lowercase alphanumeric characters and hyphens only. + When empty, a random token is generated (default behavior). + maxLength: 253 + pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ + type: string type: object status: properties: diff --git a/api/v1beta1/condition_consts.go b/api/v1beta1/condition_consts.go index 3372177db..eea2163ac 100644 --- a/api/v1beta1/condition_consts.go +++ b/api/v1beta1/condition_consts.go @@ -31,6 +31,9 @@ const ( // RbacCreationFailureReason documents a machine registration object that has RBAC creation failures. RbacCreationFailureReason = "RbacCreationFailure" + + // DuplicateTokenReason documents that another machine registration already uses the same token. + DuplicateTokenReason = "DuplicateToken" ) // Machine Inventory conditions diff --git a/api/v1beta1/machineregistration_types.go b/api/v1beta1/machineregistration_types.go index badb61f44..21d49362d 100644 --- a/api/v1beta1/machineregistration_types.go +++ b/api/v1beta1/machineregistration_types.go @@ -29,6 +29,14 @@ const ( ) type MachineRegistrationSpec struct { + // Token is an optional static token for the registration URL. + // When set, the registration URL will be deterministic: {server-url}/elemental/registration/{token}. + // Must be URL-safe: lowercase alphanumeric characters and hyphens only. + // When empty, a random token is generated (default behavior). + // +optional + // +kubebuilder:validation:Pattern=`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$` + // +kubebuilder:validation:MaxLength=253 + Token string `json:"token,omitempty"` // +optional MachineName string `json:"machineName,omitempty"` // MachineInventoryLabels label to be added to the created MachineInventory object. diff --git a/config/crd/bases/elemental.cattle.io_machineregistrations.yaml b/config/crd/bases/elemental.cattle.io_machineregistrations.yaml index 80c32ea84..329eae589 100644 --- a/config/crd/bases/elemental.cattle.io_machineregistrations.yaml +++ b/config/crd/bases/elemental.cattle.io_machineregistrations.yaml @@ -239,6 +239,15 @@ spec: type: object machineName: type: string + token: + description: |- + Token is an optional static token for the registration URL. + When set, the registration URL will be deterministic: {server-url}/elemental/registration/{token}. + Must be URL-safe: lowercase alphanumeric characters and hyphens only. + When empty, a random token is generated (default behavior). + maxLength: 253 + pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ + type: string type: object status: properties: diff --git a/controllers/machineregistration_controller.go b/controllers/machineregistration_controller.go index 079911b95..8ee23b6d4 100644 --- a/controllers/machineregistration_controller.go +++ b/controllers/machineregistration_controller.go @@ -44,6 +44,8 @@ import ( "github.com/rancher/elemental-operator/pkg/util" ) +var errDuplicateToken = errors.New("duplicate token") + // MachineRegistrationReconciler reconciles a MachineRegistration object. type MachineRegistrationReconciler struct { client.Client @@ -126,10 +128,14 @@ func (r *MachineRegistrationReconciler) reconcile(ctx context.Context, mRegistra } if err := r.setRegistrationTokenAndURL(ctx, mRegistration); err != nil { + reason := elementalv1.MissingTokenOrServerURLReason + if errors.Is(err, errDuplicateToken) { + reason = elementalv1.DuplicateTokenReason + } meta.SetStatusCondition(&mRegistration.Status.Conditions, metav1.Condition{ Type: elementalv1.ReadyCondition, Status: metav1.ConditionFalse, - Reason: elementalv1.MissingTokenOrServerURLReason, + Reason: reason, Message: err.Error(), }) return ctrl.Result{}, fmt.Errorf("failed to set registration token and url: %w", err) @@ -156,6 +162,12 @@ func (r *MachineRegistrationReconciler) reconcile(ctx context.Context, mRegistra func (r *MachineRegistrationReconciler) isReady(ctx context.Context, mRegistration *elementalv1.MachineRegistration) bool { if meta.IsStatusConditionTrue(mRegistration.Status.Conditions, elementalv1.ReadyCondition) { + // If the spec defines a static endpoint but the status token doesn't match, re-reconcile. + if mRegistration.Spec.Token != "" && mRegistration.Status.RegistrationToken != mRegistration.Spec.Token { + mRegistration.Status.RegistrationToken = "" + mRegistration.Status.RegistrationURL = "" + return false + } // Despite being on ready state we check if the serviceaccount token is still available as it can be deleted // by the control plane during backup & restore operations see: rancher/elemental#776 if err := r.Get(ctx, types.NamespacedName{ @@ -178,9 +190,16 @@ func (r *MachineRegistrationReconciler) setRegistrationTokenAndURL(ctx context.C logger.Info("Setting registration token and url") if mRegistration.Status.RegistrationToken == "" { - mRegistration.Status.RegistrationToken, err = randomtoken.Generate() - if err != nil { - return fmt.Errorf("failed to generate registration token: %w", err) + if mRegistration.Spec.Token != "" { + if err := r.checkTokenUniqueness(ctx, mRegistration); err != nil { + return err + } + mRegistration.Status.RegistrationToken = mRegistration.Spec.Token + } else { + mRegistration.Status.RegistrationToken, err = randomtoken.Generate() + if err != nil { + return fmt.Errorf("failed to generate registration token: %w", err) + } } } @@ -195,6 +214,25 @@ func (r *MachineRegistrationReconciler) setRegistrationTokenAndURL(ctx context.C return nil } +func (r *MachineRegistrationReconciler) checkTokenUniqueness(ctx context.Context, mRegistration *elementalv1.MachineRegistration) error { + mRegistrationList := &elementalv1.MachineRegistrationList{} + if err := r.List(ctx, mRegistrationList); err != nil { + return fmt.Errorf("failed to list machine registrations: %w", err) + } + + for _, m := range mRegistrationList.Items { + if m.UID == mRegistration.UID { + continue + } + if m.Status.RegistrationToken == mRegistration.Spec.Token { + return fmt.Errorf("token %q is already in use by %s/%s: %w", + mRegistration.Spec.Token, m.Namespace, m.Name, errDuplicateToken) + } + } + + return nil +} + func (r *MachineRegistrationReconciler) getRancherServerURL(ctx context.Context) (string, error) { logger := ctrl.LoggerFrom(ctx) diff --git a/controllers/machineregistration_controller_test.go b/controllers/machineregistration_controller_test.go index d889e3ce6..fcdab3cc8 100644 --- a/controllers/machineregistration_controller_test.go +++ b/controllers/machineregistration_controller_test.go @@ -187,6 +187,60 @@ var _ = Describe("setRegistrationTokenAndURL", func() { Expect(err.Error()).To(ContainSubstring("server-url is not set")) Expect(test.CleanupAndWait(ctx, cl, setting)).To(Succeed()) }) + + It("should use static registration endpoint when set", func() { + setting := &managementv3.Setting{ + ObjectMeta: metav1.ObjectMeta{ + Name: "server-url", + }, + Value: "https://example.com", + } + Expect(cl.Create(ctx, setting)).To(Succeed()) + mRegistration.Spec.Token = "my-static-endpoint" + Expect(r.setRegistrationTokenAndURL(ctx, mRegistration)).To(Succeed()) + Expect(mRegistration.Status.RegistrationToken).To(Equal("my-static-endpoint")) + Expect(mRegistration.Status.RegistrationURL).To(Equal("https://example.com/elemental/registration/my-static-endpoint")) + Expect(test.CleanupAndWait(ctx, cl, setting)).To(Succeed()) + }) + + It("should return error when static registration endpoint is already in use", func() { + setting := &managementv3.Setting{ + ObjectMeta: metav1.ObjectMeta{ + Name: "server-url", + }, + Value: "https://example.com", + } + Expect(cl.Create(ctx, setting)).To(Succeed()) + + // Create and reconcile the first registration with a static endpoint + mRegistration.Spec.Token = "shared-endpoint" + Expect(cl.Create(ctx, mRegistration)).To(Succeed()) + _, err := r.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Namespace: mRegistration.Namespace, + Name: mRegistration.Name, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + // Create a second registration with the same static endpoint + mRegistration2 := &elementalv1.MachineRegistration{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name-2", + Namespace: "default", + }, + Spec: elementalv1.MachineRegistrationSpec{ + Token: "shared-endpoint", + }, + } + Expect(cl.Create(ctx, mRegistration2)).To(Succeed()) + + err = r.setRegistrationTokenAndURL(ctx, mRegistration2) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("already in use")) + + Expect(test.CleanupAndWait(ctx, cl, setting, mRegistration2)).To(Succeed()) + }) }) var _ = Describe("createRBACObjects", func() {