diff --git a/api/v1alpha1/rollout_types.go b/api/v1alpha1/rollout_types.go index 93c9143..98b1365 100644 --- a/api/v1alpha1/rollout_types.go +++ b/api/v1alpha1/rollout_types.go @@ -243,6 +243,23 @@ type FailedHealthCheck struct { Message *string `json:"message,omitempty"` } +// TriggeredByInfo indicates what triggered a deployment. +type TriggeredByInfo struct { + // Kind indicates the type of trigger: "User" for manual deployments triggered by a user, + // or "System" for automatic deployments triggered by the rollout controller. + // +kubebuilder:validation:Enum=User;System + // +kubebuilder:validation:Required + // +required + Kind string `json:"kind"` + + // Name contains the name of the user or system that triggered the deployment. + // For user-triggered deployments, this is extracted from the "rollout.kuberik.com/deploy-user" annotation. + // For system-triggered deployments, this is typically "rollout-controller". + // +kubebuilder:validation:Required + // +required + Name string `json:"name"` +} + // DeploymentHistoryEntry represents a single entry in the deployment history. type DeploymentHistoryEntry struct { // ID is a unique auto-incrementing identifier for this history entry. @@ -267,6 +284,14 @@ type DeploymentHistoryEntry struct { // +optional Message *string `json:"message,omitempty"` + // TriggeredBy indicates what triggered this deployment. + // Kind can be "User" for manual deployments triggered by a user, or "System" for automatic deployments. + // Name contains the name of the user or system that triggered the deployment. + // For user-triggered deployments, this is extracted from the "rollout.kuberik.com/deploy-user" annotation. + // For system-triggered deployments, this is typically "rollout-controller". + // +optional + TriggeredBy *TriggeredByInfo `json:"triggeredBy,omitempty"` + // BakeStatus tracks the bake state for this deployment (e.g., None, InProgress, Succeeded, Failed, Cancelled) // The bake process ensures that the deployment is stable and healthy before marking as successful. // +optional diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 2d06400..b4d35f5 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -41,6 +41,11 @@ func (in *DeploymentHistoryEntry) DeepCopyInto(out *DeploymentHistoryEntry) { *out = new(string) **out = **in } + if in.TriggeredBy != nil { + in, out := &in.TriggeredBy, &out.TriggeredBy + *out = new(TriggeredByInfo) + **out = **in + } if in.BakeStatus != nil { in, out := &in.BakeStatus, &out.BakeStatus *out = new(string) @@ -560,6 +565,21 @@ func (in *RolloutStatus) DeepCopy() *RolloutStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TriggeredByInfo) DeepCopyInto(out *TriggeredByInfo) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TriggeredByInfo. +func (in *TriggeredByInfo) DeepCopy() *TriggeredByInfo { + if in == nil { + return nil + } + out := new(TriggeredByInfo) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *VersionInfo) DeepCopyInto(out *VersionInfo) { *out = *in diff --git a/config/crd/bases/kuberik.com_rollouts.yaml b/config/crd/bases/kuberik.com_rollouts.yaml index 08fa55b..2c1c21a 100644 --- a/config/crd/bases/kuberik.com_rollouts.yaml +++ b/config/crd/bases/kuberik.com_rollouts.yaml @@ -452,6 +452,32 @@ spec: description: Timestamp is the time when the deployment occurred. format: date-time type: string + triggeredBy: + description: |- + TriggeredBy indicates what triggered this deployment. + Kind can be "User" for manual deployments triggered by a user, or "System" for automatic deployments. + Name contains the name of the user or system that triggered the deployment. + For user-triggered deployments, this is extracted from the "rollout.kuberik.com/deploy-user" annotation. + For system-triggered deployments, this is typically "rollout-controller". + properties: + kind: + description: |- + Kind indicates the type of trigger: "User" for manual deployments triggered by a user, + or "System" for automatic deployments triggered by the rollout controller. + enum: + - User + - System + type: string + name: + description: |- + Name contains the name of the user or system that triggered the deployment. + For user-triggered deployments, this is extracted from the "rollout.kuberik.com/deploy-user" annotation. + For system-triggered deployments, this is typically "rollout-controller". + type: string + required: + - kind + - name + type: object version: description: Version is the version information that was deployed. properties: diff --git a/internal/controller/rollout_controller.go b/internal/controller/rollout_controller.go index 6074759..45db25b 100644 --- a/internal/controller/rollout_controller.go +++ b/internal/controller/rollout_controller.go @@ -1167,6 +1167,9 @@ func (r *RolloutReconciler) deployRelease(ctx context.Context, rollout *rolloutv // Generate deployment message deploymentMessage := r.generateDeploymentMessage(rollout, wantedRelease, bypassUsed, forceDeployUsed, unblockUsed) + // Extract triggered by information + triggeredBy := r.extractTriggeredByInfo(rollout, r.hasManualDeployment(rollout)) + // Find the version info for the wanted release var versionInfo rolloutv1alpha1.VersionInfo versionInfo.Tag = wantedRelease @@ -1200,6 +1203,7 @@ func (r *RolloutReconciler) deployRelease(ctx context.Context, rollout *rolloutv Version: versionInfo, Timestamp: now, Message: &deploymentMessage, + TriggeredBy: triggeredBy, BakeStatus: bakeStatus, BakeStatusMessage: bakeStatusMsg, BakeStartTime: nil, // Will be set when healthchecks become healthy @@ -1288,22 +1292,24 @@ func (r *RolloutReconciler) deployRelease(ctx context.Context, rollout *rolloutv // Clear annotations based on deployment type if forceDeployUsed { - // Clear both force-deploy and deploy-message annotations when force deploy was used + // Clear both force-deploy, deploy-message, and deploy-user annotations when force deploy was used patch := client.MergeFrom(rollout.DeepCopy()) delete(rollout.Annotations, "rollout.kuberik.com/force-deploy") delete(rollout.Annotations, "rollout.kuberik.com/deploy-message") + delete(rollout.Annotations, "rollout.kuberik.com/deploy-user") if err := r.Client.Patch(ctx, rollout, patch); err != nil { log.Error(err, "Failed to patch rollout to clear force deploy annotations") return err } } else if r.hasManualDeployment(rollout) { - // Clear only deploy-message annotation when WantedVersion was used + // Clear deploy-message and deploy-user annotations when WantedVersion was used patch := client.MergeFrom(rollout.DeepCopy()) delete(rollout.Annotations, "rollout.kuberik.com/deploy-message") + delete(rollout.Annotations, "rollout.kuberik.com/deploy-user") if err := r.Client.Patch(ctx, rollout, patch); err != nil { - log.Error(err, "Failed to patch rollout to clear deploy-message annotation") + log.Error(err, "Failed to patch rollout to clear deploy-message and deploy-user annotations") return err } } @@ -1777,6 +1783,26 @@ func (r *RolloutReconciler) getNextHistoryID(rollout *rolloutv1alpha1.Rollout) i return 1 } +// extractTriggeredByInfo extracts triggered by information from annotations. +// Returns nil if no user annotation is found (indicating a system-triggered deployment). +func (r *RolloutReconciler) extractTriggeredByInfo(rollout *rolloutv1alpha1.Rollout, isManualDeployment bool) *rolloutv1alpha1.TriggeredByInfo { + // Check for user annotation (similar to how we check deploy-message annotation) + if rollout.Annotations != nil { + if userName, exists := rollout.Annotations["rollout.kuberik.com/deploy-user"]; exists && userName != "" { + return &rolloutv1alpha1.TriggeredByInfo{ + Kind: "User", + Name: userName, + } + } + } + + // If no user annotation found, it's a system-triggered deployment + return &rolloutv1alpha1.TriggeredByInfo{ + Kind: "System", + Name: "rollout-controller", + } +} + // generateDeploymentMessage creates a descriptive message for a deployment history entry func (r *RolloutReconciler) generateDeploymentMessage(rollout *rolloutv1alpha1.Rollout, wantedRelease string, bypassUsed, forceDeployUsed, unblockUsed bool) string { var messageParts []string diff --git a/internal/controller/rollout_controller_test.go b/internal/controller/rollout_controller_test.go index e706419..d72a0e7 100644 --- a/internal/controller/rollout_controller_test.go +++ b/internal/controller/rollout_controller_test.go @@ -3570,6 +3570,342 @@ var _ = Describe("Rollout Controller", func() { // Deploy-message annotation should be cleared Expect(updatedRollout.Annotations).NotTo(HaveKey("rollout.kuberik.com/deploy-message")) }) + + It("should record user-triggered deployment with deploy-user annotation (force deploy)", func() { + By("Setting up a rollout with force-deploy and deploy-user annotations") + userTriggeredRollout := &rolloutv1alpha1.Rollout{ + ObjectMeta: metav1.ObjectMeta{ + Name: "user-triggered-force-deploy-rollout", + Namespace: namespace, + Annotations: map[string]string{ + "rollout.kuberik.com/force-deploy": "2.0.0", + "rollout.kuberik.com/deploy-user": "alice@example.com", + }, + }, + Spec: rolloutv1alpha1.RolloutSpec{ + ReleasesImagePolicy: corev1.LocalObjectReference{ + Name: "test-image-policy", + }, + }, + Status: rolloutv1alpha1.RolloutStatus{ + AvailableReleases: []rolloutv1alpha1.VersionInfo{ + {Tag: "3.0.0"}, + {Tag: "2.0.0"}, + {Tag: "1.0.0"}, + }, + }, + } + Expect(k8sClient.Create(ctx, userTriggeredRollout)).To(Succeed()) + Expect(k8sClient.Status().Update(ctx, userTriggeredRollout)).To(Succeed()) + + By("Setting up ImagePolicy with releases") + var imagePolicy imagev1beta2.ImagePolicy + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: "test-image-policy", + Namespace: namespace, + }, &imagePolicy) + Expect(err).NotTo(HaveOccurred()) + + imagePolicy.Status.LatestRef = &imagev1beta2.ImageRef{ + Tag: "2.0.0", + } + Expect(k8sClient.Status().Update(ctx, &imagePolicy)).To(Succeed()) + + By("Reconciling with force-deploy and deploy-user annotations") + controllerReconciler := &RolloutReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: "user-triggered-force-deploy-rollout", + Namespace: namespace, + }, + }) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying that TriggeredBy is set correctly in history") + var updatedRollout rolloutv1alpha1.Rollout + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: "user-triggered-force-deploy-rollout", + Namespace: namespace, + }, &updatedRollout) + Expect(err).NotTo(HaveOccurred()) + + Expect(updatedRollout.Status.History).To(HaveLen(1)) + Expect(updatedRollout.Status.History[0].Version.Tag).To(Equal("2.0.0")) + Expect(updatedRollout.Status.History[0].TriggeredBy).NotTo(BeNil()) + Expect(updatedRollout.Status.History[0].TriggeredBy.Kind).To(Equal("User")) + Expect(updatedRollout.Status.History[0].TriggeredBy.Name).To(Equal("alice@example.com")) + + // Deploy-user annotation should be cleared + Expect(updatedRollout.Annotations).NotTo(HaveKey("rollout.kuberik.com/deploy-user")) + }) + + It("should record user-triggered deployment with deploy-user annotation (wanted version)", func() { + By("Setting up a rollout with WantedVersion and deploy-user annotation") + userTriggeredRollout := &rolloutv1alpha1.Rollout{ + ObjectMeta: metav1.ObjectMeta{ + Name: "user-triggered-wanted-version-rollout", + Namespace: namespace, + Annotations: map[string]string{ + "rollout.kuberik.com/deploy-user": "bob@example.com", + }, + }, + Spec: rolloutv1alpha1.RolloutSpec{ + ReleasesImagePolicy: corev1.LocalObjectReference{ + Name: "test-image-policy", + }, + WantedVersion: stringPtr("1.0.0"), + }, + Status: rolloutv1alpha1.RolloutStatus{ + AvailableReleases: []rolloutv1alpha1.VersionInfo{ + {Tag: "1.0.0"}, + }, + }, + } + Expect(k8sClient.Create(ctx, userTriggeredRollout)).To(Succeed()) + Expect(k8sClient.Status().Update(ctx, userTriggeredRollout)).To(Succeed()) + + By("Setting up ImagePolicy with releases") + var imagePolicy imagev1beta2.ImagePolicy + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: "test-image-policy", + Namespace: namespace, + }, &imagePolicy) + Expect(err).NotTo(HaveOccurred()) + + imagePolicy.Status.LatestRef = &imagev1beta2.ImageRef{ + Tag: "1.0.0", + } + Expect(k8sClient.Status().Update(ctx, &imagePolicy)).To(Succeed()) + + By("Reconciling with WantedVersion and deploy-user annotation") + controllerReconciler := &RolloutReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: "user-triggered-wanted-version-rollout", + Namespace: namespace, + }, + }) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying that TriggeredBy is set correctly in history") + var updatedRollout rolloutv1alpha1.Rollout + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: "user-triggered-wanted-version-rollout", + Namespace: namespace, + }, &updatedRollout) + Expect(err).NotTo(HaveOccurred()) + + Expect(updatedRollout.Status.History).To(HaveLen(1)) + Expect(updatedRollout.Status.History[0].Version.Tag).To(Equal("1.0.0")) + Expect(updatedRollout.Status.History[0].TriggeredBy).NotTo(BeNil()) + Expect(updatedRollout.Status.History[0].TriggeredBy.Kind).To(Equal("User")) + Expect(updatedRollout.Status.History[0].TriggeredBy.Name).To(Equal("bob@example.com")) + + // Deploy-user annotation should be cleared + Expect(updatedRollout.Annotations).NotTo(HaveKey("rollout.kuberik.com/deploy-user")) + }) + + It("should record system-triggered deployment when no deploy-user annotation is present", func() { + By("Setting up a rollout without deploy-user annotation (automatic deployment)") + systemTriggeredRollout := &rolloutv1alpha1.Rollout{ + ObjectMeta: metav1.ObjectMeta{ + Name: "system-triggered-rollout", + Namespace: namespace, + }, + Spec: rolloutv1alpha1.RolloutSpec{ + ReleasesImagePolicy: corev1.LocalObjectReference{ + Name: "test-image-policy", + }, + }, + Status: rolloutv1alpha1.RolloutStatus{ + AvailableReleases: []rolloutv1alpha1.VersionInfo{ + {Tag: "1.0.0"}, + }, + GatedReleaseCandidates: []rolloutv1alpha1.VersionInfo{ + {Tag: "1.0.0"}, + }, + }, + } + Expect(k8sClient.Create(ctx, systemTriggeredRollout)).To(Succeed()) + Expect(k8sClient.Status().Update(ctx, systemTriggeredRollout)).To(Succeed()) + + By("Setting up ImagePolicy with releases") + var imagePolicy imagev1beta2.ImagePolicy + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: "test-image-policy", + Namespace: namespace, + }, &imagePolicy) + Expect(err).NotTo(HaveOccurred()) + + imagePolicy.Status.LatestRef = &imagev1beta2.ImageRef{ + Tag: "1.0.0", + } + Expect(k8sClient.Status().Update(ctx, &imagePolicy)).To(Succeed()) + + By("Reconciling without deploy-user annotation (automatic deployment)") + controllerReconciler := &RolloutReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: "system-triggered-rollout", + Namespace: namespace, + }, + }) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying that TriggeredBy is set to System in history") + var updatedRollout rolloutv1alpha1.Rollout + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: "system-triggered-rollout", + Namespace: namespace, + }, &updatedRollout) + Expect(err).NotTo(HaveOccurred()) + + Expect(updatedRollout.Status.History).To(HaveLen(1)) + Expect(updatedRollout.Status.History[0].Version.Tag).To(Equal("1.0.0")) + Expect(updatedRollout.Status.History[0].TriggeredBy).NotTo(BeNil()) + Expect(updatedRollout.Status.History[0].TriggeredBy.Kind).To(Equal("System")) + Expect(updatedRollout.Status.History[0].TriggeredBy.Name).To(Equal("rollout-controller")) + }) + + It("should clear deploy-user annotation when force deploy is used", func() { + By("Setting up a rollout with force-deploy and deploy-user annotations") + clearAnnotationRollout := &rolloutv1alpha1.Rollout{ + ObjectMeta: metav1.ObjectMeta{ + Name: "clear-deploy-user-force-rollout", + Namespace: namespace, + Annotations: map[string]string{ + "rollout.kuberik.com/force-deploy": "2.0.0", + "rollout.kuberik.com/deploy-user": "charlie@example.com", + "rollout.kuberik.com/deploy-message": "test message", + }, + }, + Spec: rolloutv1alpha1.RolloutSpec{ + ReleasesImagePolicy: corev1.LocalObjectReference{ + Name: "test-image-policy", + }, + }, + Status: rolloutv1alpha1.RolloutStatus{ + AvailableReleases: []rolloutv1alpha1.VersionInfo{ + {Tag: "2.0.0"}, + }, + }, + } + Expect(k8sClient.Create(ctx, clearAnnotationRollout)).To(Succeed()) + Expect(k8sClient.Status().Update(ctx, clearAnnotationRollout)).To(Succeed()) + + By("Setting up ImagePolicy with releases") + var imagePolicy imagev1beta2.ImagePolicy + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: "test-image-policy", + Namespace: namespace, + }, &imagePolicy) + Expect(err).NotTo(HaveOccurred()) + + imagePolicy.Status.LatestRef = &imagev1beta2.ImageRef{ + Tag: "2.0.0", + } + Expect(k8sClient.Status().Update(ctx, &imagePolicy)).To(Succeed()) + + By("Reconciling with force-deploy annotation") + controllerReconciler := &RolloutReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: "clear-deploy-user-force-rollout", + Namespace: namespace, + }, + }) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying that deploy-user annotation is cleared") + var updatedRollout rolloutv1alpha1.Rollout + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: "clear-deploy-user-force-rollout", + Namespace: namespace, + }, &updatedRollout) + Expect(err).NotTo(HaveOccurred()) + + // All annotations should be cleared after force deploy + Expect(updatedRollout.Annotations).NotTo(HaveKey("rollout.kuberik.com/force-deploy")) + Expect(updatedRollout.Annotations).NotTo(HaveKey("rollout.kuberik.com/deploy-message")) + Expect(updatedRollout.Annotations).NotTo(HaveKey("rollout.kuberik.com/deploy-user")) + }) + + It("should clear deploy-user annotation when WantedVersion is used", func() { + By("Setting up a rollout with WantedVersion and deploy-user annotation") + clearAnnotationRollout := &rolloutv1alpha1.Rollout{ + ObjectMeta: metav1.ObjectMeta{ + Name: "clear-deploy-user-wanted-rollout", + Namespace: namespace, + Annotations: map[string]string{ + "rollout.kuberik.com/deploy-user": "dave@example.com", + "rollout.kuberik.com/deploy-message": "test message", + }, + }, + Spec: rolloutv1alpha1.RolloutSpec{ + ReleasesImagePolicy: corev1.LocalObjectReference{ + Name: "test-image-policy", + }, + WantedVersion: stringPtr("1.0.0"), + }, + Status: rolloutv1alpha1.RolloutStatus{ + AvailableReleases: []rolloutv1alpha1.VersionInfo{ + {Tag: "1.0.0"}, + }, + }, + } + Expect(k8sClient.Create(ctx, clearAnnotationRollout)).To(Succeed()) + Expect(k8sClient.Status().Update(ctx, clearAnnotationRollout)).To(Succeed()) + + By("Setting up ImagePolicy with releases") + var imagePolicy imagev1beta2.ImagePolicy + err := k8sClient.Get(ctx, types.NamespacedName{ + Name: "test-image-policy", + Namespace: namespace, + }, &imagePolicy) + Expect(err).NotTo(HaveOccurred()) + + imagePolicy.Status.LatestRef = &imagev1beta2.ImageRef{ + Tag: "1.0.0", + } + Expect(k8sClient.Status().Update(ctx, &imagePolicy)).To(Succeed()) + + By("Reconciling with WantedVersion") + controllerReconciler := &RolloutReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + _, err = controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: "clear-deploy-user-wanted-rollout", + Namespace: namespace, + }, + }) + Expect(err).NotTo(HaveOccurred()) + + By("Verifying that deploy-user annotation is cleared") + var updatedRollout rolloutv1alpha1.Rollout + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: "clear-deploy-user-wanted-rollout", + Namespace: namespace, + }, &updatedRollout) + Expect(err).NotTo(HaveOccurred()) + + // Deploy-user and deploy-message annotations should be cleared + Expect(updatedRollout.Annotations).NotTo(HaveKey("rollout.kuberik.com/deploy-message")) + Expect(updatedRollout.Annotations).NotTo(HaveKey("rollout.kuberik.com/deploy-user")) + }) }) It("should continue status updates even when deployment is blocked by failed bake status", func() { @@ -5167,6 +5503,105 @@ func (f *FakeClock) Add(d time.Duration) { f.now = metav1.NewTime(f.now.Add(d)) } +var _ = Describe("extractTriggeredByInfo", func() { + var reconciler *RolloutReconciler + + BeforeEach(func() { + reconciler = &RolloutReconciler{} + }) + + It("should return User kind when deploy-user annotation is present", func() { + rollout := &rolloutv1alpha1.Rollout{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "rollout.kuberik.com/deploy-user": "alice@example.com", + }, + }, + } + + result := reconciler.extractTriggeredByInfo(rollout, true) + + Expect(result).NotTo(BeNil()) + Expect(result.Kind).To(Equal("User")) + Expect(result.Name).To(Equal("alice@example.com")) + }) + + It("should return User kind even when isManualDeployment is false but annotation exists", func() { + rollout := &rolloutv1alpha1.Rollout{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "rollout.kuberik.com/deploy-user": "bob@example.com", + }, + }, + } + + result := reconciler.extractTriggeredByInfo(rollout, false) + + Expect(result).NotTo(BeNil()) + Expect(result.Kind).To(Equal("User")) + Expect(result.Name).To(Equal("bob@example.com")) + }) + + It("should return System kind when deploy-user annotation is not present", func() { + rollout := &rolloutv1alpha1.Rollout{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{}, + }, + } + + result := reconciler.extractTriggeredByInfo(rollout, false) + + Expect(result).NotTo(BeNil()) + Expect(result.Kind).To(Equal("System")) + Expect(result.Name).To(Equal("rollout-controller")) + }) + + It("should return System kind when annotations are nil", func() { + rollout := &rolloutv1alpha1.Rollout{ + ObjectMeta: metav1.ObjectMeta{}, + } + + result := reconciler.extractTriggeredByInfo(rollout, false) + + Expect(result).NotTo(BeNil()) + Expect(result.Kind).To(Equal("System")) + Expect(result.Name).To(Equal("rollout-controller")) + }) + + It("should return System kind when deploy-user annotation is empty string", func() { + rollout := &rolloutv1alpha1.Rollout{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "rollout.kuberik.com/deploy-user": "", + }, + }, + } + + result := reconciler.extractTriggeredByInfo(rollout, false) + + Expect(result).NotTo(BeNil()) + Expect(result.Kind).To(Equal("System")) + Expect(result.Name).To(Equal("rollout-controller")) + }) + + It("should return User kind with correct username when annotation has different user", func() { + rollout := &rolloutv1alpha1.Rollout{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "rollout.kuberik.com/deploy-user": "charlie@example.com", + "rollout.kuberik.com/deploy-message": "test message", + }, + }, + } + + result := reconciler.extractTriggeredByInfo(rollout, true) + + Expect(result).NotTo(BeNil()) + Expect(result.Kind).To(Equal("User")) + Expect(result.Name).To(Equal("charlie@example.com")) + }) +}) + // NewFakeClock creates a FakeClock with time truncated to second precision func NewFakeClock() *FakeClock { now := time.Now().Truncate(time.Second)