diff --git a/api/v1alpha1/release_conditions.go b/api/v1alpha1/release_conditions.go index a913536e..2d36f82f 100644 --- a/api/v1alpha1/release_conditions.go +++ b/api/v1alpha1/release_conditions.go @@ -26,6 +26,9 @@ const ( // ProgressingReason is the reason set when an action is progressing ProgressingReason conditions.ConditionReason = "Progressing" + // QueuedReason is the reason set when the action is queued + QueuedReason conditions.ConditionReason = "Queued" + // SucceededReason is the reason set when an action succeeds SucceededReason conditions.ConditionReason = "Succeeded" ) diff --git a/api/v1alpha1/release_types.go b/api/v1alpha1/release_types.go index be572845..829838f3 100644 --- a/api/v1alpha1/release_types.go +++ b/api/v1alpha1/release_types.go @@ -222,6 +222,12 @@ func (r *Release) IsReleasing() bool { return r.isPhaseProgressing(releasedConditionType) } +// IsReleaseQueued checks whether the Release is queued. +func (r *Release) IsReleaseQueued() bool { + condition := meta.FindStatusCondition(r.Status.Conditions, releasedConditionType.String()) + return condition != nil && condition.Status == metav1.ConditionFalse && condition.Reason == QueuedReason.String() +} + // IsValid checks whether the Release validation has finished successfully. func (r *Release) IsValid() bool { return meta.IsStatusConditionTrue(r.Status.Conditions, validatedConditionType.String()) @@ -383,6 +389,15 @@ func (r *Release) MarkReleaseFailed(message string) { ) } +// MarkReleaseQueued marks the Release as queued. +func (r *Release) MarkReleaseQueued(message string) { + if !r.IsReleasing() || r.HasReleaseFinished() { + return + } + + conditions.SetConditionWithMessage(&r.Status.Conditions, releasedConditionType, metav1.ConditionFalse, QueuedReason, message) +} + // MarkValidated marks the Release as validated. func (r *Release) MarkValidated() { if r.IsValid() { @@ -454,7 +469,8 @@ func (r *Release) hasPhaseFinished(conditionType conditions.ConditionType) bool case condition.Status == metav1.ConditionTrue: return true default: - return condition.Status == metav1.ConditionFalse && condition.Reason != ProgressingReason.String() + return condition.Status == metav1.ConditionFalse && condition.Reason != ProgressingReason.String() && + condition.Reason != QueuedReason.String() } } diff --git a/api/v1alpha1/release_types_test.go b/api/v1alpha1/release_types_test.go index d3ad60ca..439bfea5 100644 --- a/api/v1alpha1/release_types_test.go +++ b/api/v1alpha1/release_types_test.go @@ -342,6 +342,38 @@ var _ = Describe("Release type", func() { }) }) + When("IsReleaseQueued method is called", func() { + var release *Release + + BeforeEach(func() { + release = &Release{} + }) + + It("should return false when the queued condition is missing", func() { + Expect(release.IsReleaseQueued()).To(BeFalse()) + }) + + It("should return false when the released condition status is True", func() { + conditions.SetCondition(&release.Status.Conditions, releasedConditionType, metav1.ConditionTrue, QueuedReason) + Expect(release.IsReleaseQueued()).To(BeFalse()) + }) + + It("should return true when the released condition status is False and the reason is Queued", func() { + conditions.SetCondition(&release.Status.Conditions, releasedConditionType, metav1.ConditionFalse, QueuedReason) + Expect(release.IsReleaseQueued()).To(BeTrue()) + }) + + It("should return false when the released condition status is False and the reason is not Queued", func() { + conditions.SetCondition(&release.Status.Conditions, releasedConditionType, metav1.ConditionFalse, FailedReason) + Expect(release.IsReleaseQueued()).To(BeFalse()) + }) + + It("should return false when the released condition status is Unknown", func() { + conditions.SetCondition(&release.Status.Conditions, releasedConditionType, metav1.ConditionUnknown, QueuedReason) + Expect(release.IsReleaseQueued()).To(BeFalse()) + }) + }) + When("IsValid method is called", func() { var release *Release @@ -756,6 +788,42 @@ var _ = Describe("Release type", func() { }) }) + When("MarkReleaseQueued method is called", func() { + var release *Release + + BeforeEach(func() { + release = &Release{} + }) + + It("should do nothing if the Release has not started", func() { + release.MarkReleaseQueued("") + Expect(release.Status.CompletionTime).To(BeNil()) + }) + + It("should do nothing if the Release has finished", func() { + release.MarkReleasing("") + release.MarkReleased() + Expect(release.Status.CompletionTime.IsZero()).To(BeFalse()) + release.Status.CompletionTime = &metav1.Time{} + release.MarkReleaseQueued("") + Expect(release.Status.CompletionTime.IsZero()).To(BeTrue()) + }) + + It("should register the condition", func() { + Expect(release.Status.Conditions).To(HaveLen(0)) + release.MarkReleasing("") + release.MarkReleaseQueued("foo") + + condition := meta.FindStatusCondition(release.Status.Conditions, releasedConditionType.String()) + Expect(condition).NotTo(BeNil()) + Expect(*condition).To(MatchFields(IgnoreExtras, Fields{ + "Message": Equal("foo"), + "Reason": Equal(QueuedReason.String()), + "Status": Equal(metav1.ConditionFalse), + })) + }) + }) + When("MarkValidated method is called", func() { var release *Release @@ -879,7 +947,12 @@ var _ = Describe("Release type", func() { Expect(release.hasPhaseFinished(deployedConditionType)).To(BeFalse()) }) - It("should return true when the condition status is False and the reason is not Progressing", func() { + It("should return false when the condition status is False and the reason is Queued", func() { + conditions.SetCondition(&release.Status.Conditions, deployedConditionType, metav1.ConditionFalse, QueuedReason) + Expect(release.hasPhaseFinished(deployedConditionType)).To(BeFalse()) + }) + + It("should return true when the condition status is False and the reason is not Progressing nor Queued", func() { conditions.SetCondition(&release.Status.Conditions, deployedConditionType, metav1.ConditionFalse, FailedReason) Expect(release.hasPhaseFinished(deployedConditionType)).To(BeTrue()) }) diff --git a/controllers/release/adapter.go b/controllers/release/adapter.go index 3b34336a..cb4f0d6f 100644 --- a/controllers/release/adapter.go +++ b/controllers/release/adapter.go @@ -187,6 +187,22 @@ func (a *adapter) EnsureReleaseIsProcessed() (controller.OperationResult, error) return controller.ContinueProcessing() } + pipelineRuns, err := a.loader.GetActiveManagedReleasePipelineRuns(a.ctx, a.client, a.release) + if err != nil { + return controller.RequeueWithError(err) + } + + // Requeue the Release if a PipelineRun is already running for the same Application + if len(pipelineRuns.Items) > 0 { + patch := client.MergeFrom(a.release.DeepCopy()) + a.release.MarkReleaseQueued(fmt.Sprintf("%d Release PipelineRun(s) running for the same Application", len(pipelineRuns.Items))) + err := a.client.Status().Patch(a.ctx, a.release, patch) + if err != nil { + return controller.RequeueWithError(err) + } + return controller.RequeueAfter(time.Minute, nil) + } + pipelineRun, err := a.loader.GetManagedReleasePipelineRun(a.ctx, a.client, a.release) if err != nil && !errors.IsNotFound(err) { return controller.RequeueWithError(err) diff --git a/controllers/release/adapter_test.go b/controllers/release/adapter_test.go index 55b9abe4..178d72af 100644 --- a/controllers/release/adapter_test.go +++ b/controllers/release/adapter_test.go @@ -296,6 +296,17 @@ var _ = Describe("Release adapter", Ordered, func() { Expect(adapter.release.IsReleasing()).To(BeTrue()) }) + It("should mark the Release as releasing even if it is queued", func() { + adapter.release.MarkReleasing("") + adapter.release.MarkReleaseQueued("") + Expect(adapter.release.IsReleaseQueued()).To(BeTrue()) + Expect(adapter.release.IsReleasing()).To(BeFalse()) + result, err := adapter.EnsureReleaseIsRunning() + Expect(!result.RequeueRequest && !result.CancelRequest).To(BeTrue()) + Expect(err).NotTo(HaveOccurred()) + Expect(adapter.release.IsReleasing()).To(BeTrue()) + }) + It("should do nothing if the release is already running", func() { adapter.release.MarkReleasing("") @@ -351,6 +362,33 @@ var _ = Describe("Release adapter", Ordered, func() { Expect(err).NotTo(HaveOccurred()) }) + It("should queue the Release if another PipelineRun is running for the same Application", func() { + adapter.ctx = toolkit.GetMockedContext(ctx, []toolkit.MockData{ + { + ContextKey: loader.ActiveManagedReleasePipelineRunsContextKey, + Resource: &tektonv1.PipelineRunList{ + Items: []tektonv1.PipelineRun{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pipeline-run", + Namespace: "default", + }, + }, + }, + }, + }, + }) + + adapter.release.MarkReleasing("") + result, err := adapter.EnsureReleaseIsProcessed() + Expect(result.RequeueRequest && !result.CancelRequest).To(BeTrue()) + Expect(result.RequeueDelay).To(Equal(time.Minute)) + Expect(err).NotTo(HaveOccurred()) + Expect(adapter.release.IsProcessing()).To(BeFalse()) + Expect(adapter.release.IsReleaseQueued()).To(BeTrue()) + + }) + It("should register the processing data if the PipelineRun already exists", func() { adapter.ctx = toolkit.GetMockedContext(ctx, []toolkit.MockData{ { diff --git a/loader/loader.go b/loader/loader.go index c86f3434..834c979f 100644 --- a/loader/loader.go +++ b/loader/loader.go @@ -22,6 +22,7 @@ import ( ) type ObjectLoader interface { + GetActiveManagedReleasePipelineRuns(ctx context.Context, cli client.Client, release *v1alpha1.Release) (*tektonv1.PipelineRunList, error) GetActiveReleasePlanAdmission(ctx context.Context, cli client.Client, releasePlan *v1alpha1.ReleasePlan) (*v1alpha1.ReleasePlanAdmission, error) GetActiveReleasePlanAdmissionFromRelease(ctx context.Context, cli client.Client, release *v1alpha1.Release) (*v1alpha1.ReleasePlanAdmission, error) GetApplication(ctx context.Context, cli client.Client, releasePlan *v1alpha1.ReleasePlan) (*applicationapiv1alpha1.Application, error) @@ -44,6 +45,32 @@ func NewLoader() ObjectLoader { return &loader{} } +// GetActiveManagedReleasePipelineRuns returns all active managed Release PipelineRuns for the Application being Released. +// PipelineRuns for the Release passed as an argument are ignored. +func (l *loader) GetActiveManagedReleasePipelineRuns(ctx context.Context, cli client.Client, release *v1alpha1.Release) (*tektonv1.PipelineRunList, error) { + releasePlan, err := l.GetReleasePlan(ctx, cli, release) + if err != nil { + return nil, err + } + + pipelineRuns := &tektonv1.PipelineRunList{} + err = cli.List(ctx, pipelineRuns, + client.InNamespace(releasePlan.Spec.Target), + client.MatchingLabels{ + metadata.ApplicationNameLabel: releasePlan.Spec.Application, + }) + + for i := len(pipelineRuns.Items) - 1; i >= 0; i-- { + releaseName, _ := pipelineRuns.Items[i].Labels[metadata.ReleaseNameLabel] + if releaseName == release.Name || pipelineRuns.Items[i].IsDone() { + // Remove completed PipelineRuns or PipelineRuns triggered for the Release passed as argument + pipelineRuns.Items = append(pipelineRuns.Items[:i], pipelineRuns.Items[i+1:]...) + } + } + + return pipelineRuns, nil +} + // GetActiveReleasePlanAdmission returns the ReleasePlanAdmission targeted by the given ReleasePlan. // Only ReleasePlanAdmissions with the 'auto-release' label set to true (or missing the label, which is // treated the same as having the label and it being set to true) will be searched for. If a matching diff --git a/loader/loader_mock.go b/loader/loader_mock.go index 652234bc..f6b3d857 100644 --- a/loader/loader_mock.go +++ b/loader/loader_mock.go @@ -15,7 +15,8 @@ import ( ) const ( - ApplicationComponentsContextKey toolkit.ContextKey = iota + ActiveManagedReleasePipelineRunsContextKey toolkit.ContextKey = iota + ApplicationComponentsContextKey ApplicationContextKey EnterpriseContractConfigMapContextKey EnterpriseContractPolicyContextKey @@ -41,6 +42,13 @@ func NewMockLoader() ObjectLoader { } } +func (l *mockLoader) GetActiveManagedReleasePipelineRuns(ctx context.Context, cli client.Client, release *v1alpha1.Release) (*tektonv1.PipelineRunList, error) { + if ctx.Value(ActiveManagedReleasePipelineRunsContextKey) == nil { + return l.loader.GetActiveManagedReleasePipelineRuns(ctx, cli, release) + } + return toolkit.GetMockedResourceAndErrorFromContext(ctx, ActiveManagedReleasePipelineRunsContextKey, &tektonv1.PipelineRunList{}) +} + // GetActiveReleasePlanAdmission returns the resource and error passed as values of the context. func (l *mockLoader) GetActiveReleasePlanAdmission(ctx context.Context, cli client.Client, releasePlan *v1alpha1.ReleasePlan) (*v1alpha1.ReleasePlanAdmission, error) { if ctx.Value(ReleasePlanAdmissionContextKey) == nil { diff --git a/loader/loader_mock_test.go b/loader/loader_mock_test.go index 4ddf5d89..9be45e66 100644 --- a/loader/loader_mock_test.go +++ b/loader/loader_mock_test.go @@ -21,6 +21,21 @@ var _ = Describe("Release Adapter", Ordered, func() { loader = NewMockLoader() }) + When("calling GetActiveManagedReleasePipelineRuns", func() { + It("returns the resource and error from the context", func() { + pipelineRuns := &tektonv1.PipelineRunList{} + mockContext := toolkit.GetMockedContext(ctx, []toolkit.MockData{ + { + ContextKey: ActiveManagedReleasePipelineRunsContextKey, + Resource: pipelineRuns, + }, + }) + resource, err := loader.GetActiveManagedReleasePipelineRuns(mockContext, nil, nil) + Expect(resource).To(Equal(pipelineRuns)) + Expect(err).To(BeNil()) + }) + }) + When("calling GetActiveReleasePlanAdmission", func() { It("returns the resource and error from the context", func() { releasePlanAdmission := &v1alpha1.ReleasePlanAdmission{} diff --git a/loader/loader_test.go b/loader/loader_test.go index cde166b6..d394b6e6 100644 --- a/loader/loader_test.go +++ b/loader/loader_test.go @@ -51,6 +51,34 @@ var _ = Describe("Release Adapter", Ordered, func() { loader = NewLoader() }) + When("calling GetActiveManagedReleasePipelineRuns", func() { + var pipelineRunOne, pipelineRunTwo *tektonv1.PipelineRun + + BeforeEach(func() { + pipelineRunOne = pipelineRun.DeepCopy() + pipelineRunOne.Labels[metadata.ReleaseNameLabel] = "foo" + pipelineRunOne.Name = "pr-one" + pipelineRunOne.ResourceVersion = "" + pipelineRunTwo = pipelineRun.DeepCopy() + pipelineRunTwo.Name = "pr-two" + pipelineRunTwo.ResourceVersion = "" + Expect(k8sClient.Create(ctx, pipelineRunOne)).To(Succeed()) + Expect(k8sClient.Create(ctx, pipelineRunTwo)).To(Succeed()) + }) + + AfterEach(func() { + Expect(k8sClient.Delete(ctx, pipelineRunOne)).To(Succeed()) + Expect(k8sClient.Delete(ctx, pipelineRunTwo)).To(Succeed()) + }) + + It("returns the requested list of PipelineRuns", func() { + Eventually(func() bool { + returnedObject, err := loader.GetActiveManagedReleasePipelineRuns(ctx, k8sClient, release) + return returnedObject != &tektonv1.PipelineRunList{} && err == nil && len(returnedObject.Items) == 1 + }) + }) + }) + When("calling GetActiveReleasePlanAdmission", func() { It("returns an active release plan admission", func() { returnedObject, err := loader.GetActiveReleasePlanAdmission(ctx, k8sClient, releasePlan) @@ -475,6 +503,7 @@ var _ = Describe("Release Adapter", Ordered, func() { pipelineRun = &tektonv1.PipelineRun{ ObjectMeta: metav1.ObjectMeta{ Labels: map[string]string{ + metadata.ApplicationNameLabel: releasePlan.Spec.Application, metadata.ReleaseNameLabel: release.Name, metadata.ReleaseNamespaceLabel: release.Namespace, },