diff --git a/.obs/chartfile/elemental-operator-crds-helm/templates/crds.yaml b/.obs/chartfile/elemental-operator-crds-helm/templates/crds.yaml index a2922ae34..a9f028630 100644 --- a/.obs/chartfile/elemental-operator-crds-helm/templates/crds.yaml +++ b/.obs/chartfile/elemental-operator-crds-helm/templates/crds.yaml @@ -1006,6 +1006,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 a3be7a8f6..b14fcbbe9 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 79980ee69..71c15e3b4 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() {