From 84774a8c28390492875a59ef7dd259a48e64ada3 Mon Sep 17 00:00:00 2001 From: Johnny Bieren Date: Mon, 6 Nov 2023 17:23:49 -0500 Subject: [PATCH] feat(RHTAPREL-278): add match info to releasePlans and RPAs This commit adds a ReleasePlanAdmission controller and modifies the ReleasePlan controller. The controllers perform similar actions - the update their respective resource's Status to say whether or not it is matched to its paired resource. Signed-off-by: Johnny Bieren --- PROJECT | 1 + api/v1alpha1/match_conditions.go | 14 + api/v1alpha1/release_types_test.go | 2 +- api/v1alpha1/releaseplan_types.go | 56 ++- api/v1alpha1/releaseplan_types_test.go | 143 ++++++ api/v1alpha1/releaseplanadmission_types.go | 45 +- .../releaseplanadmission_types_test.go | 129 ++++++ api/v1alpha1/zz_generated.deepcopy.go | 33 +- cache/cache.go | 11 + ...udio.redhat.com_releaseplanadmissions.yaml | 17 +- .../appstudio.redhat.com_releaseplans.yaml | 16 +- config/rbac/role.yaml | 28 ++ controllers/controllers.go | 2 + controllers/release/controller.go | 2 + controllers/releaseplan/adapter.go | 25 + controllers/releaseplan/adapter_test.go | 89 +++- controllers/releaseplan/controller.go | 18 +- controllers/releaseplanadmission/adapter.go | 72 +++ .../releaseplanadmission/adapter_test.go | 248 ++++++++++ .../releaseplanadmission/controller.go | 84 ++++ .../releaseplanadmission/controller_test.go | 73 +++ .../releaseplanadmission/suite_test.go | 92 ++++ controllers/utils/handlers/enqueue_matched.go | 95 ++++ .../utils/handlers/enqueue_matched_test.go | 218 +++++++++ controllers/utils/handlers/suite_test.go | 55 +++ controllers/utils/predicates/predicates.go | 132 ++++++ .../utils/predicates/predicates_test.go | 435 ++++++++++++++++++ controllers/utils/predicates/suite_test.go | 55 +++ loader/loader.go | 100 ++-- loader/loader_mock.go | 18 + loader/loader_mock_test.go | 30 ++ loader/loader_test.go | 105 ++++- loader/suite_test.go | 4 +- 33 files changed, 2372 insertions(+), 75 deletions(-) create mode 100644 api/v1alpha1/match_conditions.go create mode 100644 api/v1alpha1/releaseplan_types_test.go create mode 100644 api/v1alpha1/releaseplanadmission_types_test.go create mode 100644 controllers/releaseplanadmission/adapter.go create mode 100644 controllers/releaseplanadmission/adapter_test.go create mode 100644 controllers/releaseplanadmission/controller.go create mode 100644 controllers/releaseplanadmission/controller_test.go create mode 100644 controllers/releaseplanadmission/suite_test.go create mode 100644 controllers/utils/handlers/enqueue_matched.go create mode 100644 controllers/utils/handlers/enqueue_matched_test.go create mode 100644 controllers/utils/handlers/suite_test.go create mode 100644 controllers/utils/predicates/predicates.go create mode 100644 controllers/utils/predicates/predicates_test.go create mode 100644 controllers/utils/predicates/suite_test.go diff --git a/PROJECT b/PROJECT index 760fdeff..e21377cd 100644 --- a/PROJECT +++ b/PROJECT @@ -40,6 +40,7 @@ resources: - api: crdVersion: v1 namespaced: true + controller: true domain: redhat.com group: appstudio kind: ReleasePlanAdmission diff --git a/api/v1alpha1/match_conditions.go b/api/v1alpha1/match_conditions.go new file mode 100644 index 00000000..63bc8a2d --- /dev/null +++ b/api/v1alpha1/match_conditions.go @@ -0,0 +1,14 @@ +package v1alpha1 + +import "github.com/redhat-appstudio/operator-toolkit/conditions" + +const ( + // matchedConditionType is the type used to track the status of the ReleasePlan being matched to a + // ReleasePlanAdmission or vice versa + MatchedConditionType conditions.ConditionType = "Matched" +) + +const ( + // MatchedReason is the reason set when a resource is matched + MatchedReason conditions.ConditionReason = "Matched" +) diff --git a/api/v1alpha1/release_types_test.go b/api/v1alpha1/release_types_test.go index 99f23bb2..5c248ca8 100644 --- a/api/v1alpha1/release_types_test.go +++ b/api/v1alpha1/release_types_test.go @@ -1,5 +1,5 @@ /* -Copyright 2022. +Copyright 2023. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/api/v1alpha1/releaseplan_types.go b/api/v1alpha1/releaseplan_types.go index 56f4ac42..bbb68820 100644 --- a/api/v1alpha1/releaseplan_types.go +++ b/api/v1alpha1/releaseplan_types.go @@ -17,9 +17,15 @@ limitations under the License. package v1alpha1 import ( + "fmt" + + "github.com/redhat-appstudio/operator-toolkit/conditions" + "github.com/redhat-appstudio/release-service/metadata" tektonutils "github.com/redhat-appstudio/release-service/tekton/utils" + "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" ) // ReleasePlanSpec defines the desired state of ReleasePlan. @@ -51,17 +57,29 @@ type ReleasePlanSpec struct { Target string `json:"target"` } +// MatchedReleasePlanAdmission defines the relevant information for a matched ReleasePlanAdmission. +type MatchedReleasePlanAdmission struct { + // Name contains the namespaced name of the releasePlanAdmission + // +kubebuilder:validation:Pattern=^[a-z0-9]([-a-z0-9]*[a-z0-9])?\/[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + // +optional + Name string `json:"name,omitempty"` + + // Active indicates whether the ReleasePlanAdmission is set to auto-release or not + // +kubebuilder:default:false + // +optional + Active bool `json:"active,omitempty"` +} + // ReleasePlanStatus defines the observed state of ReleasePlan. type ReleasePlanStatus struct { // Conditions represent the latest available observations for the releasePlan // +optional Conditions []metav1.Condition `json:"conditions"` - // ReleasePlanAdmission contains the namespaced name of the releasePlanAdmission this ReleasePlan is + // ReleasePlanAdmission contains the information of the releasePlanAdmission this ReleasePlan is // matched to - // +kubebuilder:validation:Pattern=^[a-z0-9]([-a-z0-9]*[a-z0-9])?\/[a-z0-9]([-a-z0-9]*[a-z0-9])?$ // +optional - ReleasePlanAdmission string `json:"releasePlanAdmission,omitempty"` + ReleasePlanAdmission MatchedReleasePlanAdmission `json:"releasePlanAdmission,omitempty"` } // +kubebuilder:object:root=true @@ -79,6 +97,38 @@ type ReleasePlan struct { Status ReleasePlanStatus `json:"status,omitempty"` } +// IsMatched checks whether the ReleasePlan is matched to a ReleasePlanAdmission. +func (rp *ReleasePlan) IsMatched() bool { + return meta.IsStatusConditionTrue(rp.Status.Conditions, MatchedConditionType.String()) +} + +// MarkMatched marks the ReleasePlan as matched to a given ReleasePlanAdmission. +func (rp *ReleasePlan) MarkMatched(releasePlanAdmission *ReleasePlanAdmission) { + rp.setMatchedStatus(releasePlanAdmission, metav1.ConditionTrue) +} + +// MarkUnmatched marks the ReleasePlan as not matched to any ReleasePlanAdmission. +func (rp *ReleasePlan) MarkUnmatched() { + if !rp.IsMatched() { + return + } + + rp.setMatchedStatus(nil, metav1.ConditionFalse) +} + +// setMatchedStatus sets the ReleasePlan Matched condition based on the passed releasePlanAdmission and status. +func (rp *ReleasePlan) setMatchedStatus(releasePlanAdmission *ReleasePlanAdmission, status metav1.ConditionStatus) { + rp.Status.ReleasePlanAdmission = MatchedReleasePlanAdmission{} + + if releasePlanAdmission != nil { + rp.Status.ReleasePlanAdmission.Name = fmt.Sprintf("%s%c%s", releasePlanAdmission.GetNamespace(), + types.Separator, releasePlanAdmission.GetName()) + rp.Status.ReleasePlanAdmission.Active = (releasePlanAdmission.GetLabels()[metadata.AutoReleaseLabel] == "true") + } + + conditions.SetCondition(&rp.Status.Conditions, MatchedConditionType, status, MatchedReason) +} + // +kubebuilder:object:root=true // ReleasePlanList contains a list of ReleasePlan. diff --git a/api/v1alpha1/releaseplan_types_test.go b/api/v1alpha1/releaseplan_types_test.go new file mode 100644 index 00000000..c2e13a02 --- /dev/null +++ b/api/v1alpha1/releaseplan_types_test.go @@ -0,0 +1,143 @@ +/* +Copyright 2022. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/redhat-appstudio/operator-toolkit/conditions" + "github.com/redhat-appstudio/release-service/metadata" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var _ = Describe("ReleasePlan type", func() { + When("IsMatched method is called", func() { + var releasePlan *ReleasePlan + + BeforeEach(func() { + releasePlan = &ReleasePlan{} + }) + + It("should return true when the matched condition status is True", func() { + conditions.SetCondition(&releasePlan.Status.Conditions, MatchedConditionType, metav1.ConditionTrue, "") + Expect(releasePlan.IsMatched()).To(BeTrue()) + }) + + It("should return false when the matched condition status is False", func() { + conditions.SetCondition(&releasePlan.Status.Conditions, MatchedConditionType, metav1.ConditionFalse, "") + Expect(releasePlan.IsMatched()).To(BeFalse()) + }) + + It("should return false when the matched condition status is Unknown", func() { + conditions.SetCondition(&releasePlan.Status.Conditions, MatchedConditionType, metav1.ConditionUnknown, "") + Expect(releasePlan.IsMatched()).To(BeFalse()) + }) + + It("should return false when the matched condition is missing", func() { + Expect(releasePlan.IsMatched()).To(BeFalse()) + }) + }) + + When("MarkMatched method is called", func() { + var releasePlan *ReleasePlan + var releasePlanAdmission *ReleasePlanAdmission + + BeforeEach(func() { + releasePlan = &ReleasePlan{} + releasePlanAdmission = &ReleasePlanAdmission{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rpa", + Namespace: "default", + Labels: map[string]string{ + metadata.AutoReleaseLabel: "false", + }, + }, + } + }) + + It("should mark the ReleasePlan as matched", func() { + releasePlan.MarkMatched(releasePlanAdmission) + Expect(releasePlan.Status.ReleasePlanAdmission.Name).To(Equal("default/rpa")) + Expect(releasePlan.Status.ReleasePlanAdmission.Active).To(BeFalse()) + condition := meta.FindStatusCondition(releasePlan.Status.Conditions, MatchedConditionType.String()) + Expect(condition).NotTo(BeNil()) + Expect(condition.Status).To(Equal(metav1.ConditionTrue)) + }) + }) + + When("MarkUnmatched method is called", func() { + var releasePlan *ReleasePlan + var releasePlanAdmission *ReleasePlanAdmission + + BeforeEach(func() { + releasePlan = &ReleasePlan{} + releasePlanAdmission = &ReleasePlanAdmission{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rpa", + Namespace: "default", + Labels: map[string]string{ + metadata.AutoReleaseLabel: "false", + }, + }, + } + }) + + It("should do nothing if the ReleasePlan is not matched", func() { + releasePlan.setMatchedStatus(releasePlanAdmission, metav1.ConditionFalse) // IsMatched relies on the condition, not value of RPA + releasePlan.MarkUnmatched() + Expect(releasePlan.Status.ReleasePlanAdmission.Name).To(Equal("default/rpa")) + }) + + It("should mark the ReleasePlan as unmatched", func() { + releasePlan.MarkMatched(releasePlanAdmission) + releasePlan.MarkUnmatched() + Expect(releasePlan.Status.ReleasePlanAdmission).To(Equal(MatchedReleasePlanAdmission{})) + condition := meta.FindStatusCondition(releasePlan.Status.Conditions, MatchedConditionType.String()) + Expect(condition).NotTo(BeNil()) + Expect(condition.Status).To(Equal(metav1.ConditionFalse)) + }) + }) + + When("setMatchedStatus method is called", func() { + var releasePlan *ReleasePlan + var releasePlanAdmission *ReleasePlanAdmission + + BeforeEach(func() { + releasePlan = &ReleasePlan{} + releasePlanAdmission = &ReleasePlanAdmission{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rpa", + Namespace: "default", + Labels: map[string]string{ + metadata.AutoReleaseLabel: "true", + }, + }, + } + }) + + It("should set the ReleasePlanAdmission and matched condition", func() { + releasePlan.setMatchedStatus(releasePlanAdmission, metav1.ConditionUnknown) + Expect(releasePlan.Status.ReleasePlanAdmission.Name).To(Equal("default/rpa")) + Expect(releasePlan.Status.ReleasePlanAdmission.Active).To(BeTrue()) + condition := meta.FindStatusCondition(releasePlan.Status.Conditions, MatchedConditionType.String()) + Expect(condition).NotTo(BeNil()) + Expect(condition.Status).To(Equal(metav1.ConditionUnknown)) + }) + }) +}) diff --git a/api/v1alpha1/releaseplanadmission_types.go b/api/v1alpha1/releaseplanadmission_types.go index 4cedefd4..241e3ea0 100644 --- a/api/v1alpha1/releaseplanadmission_types.go +++ b/api/v1alpha1/releaseplanadmission_types.go @@ -17,9 +17,15 @@ limitations under the License. package v1alpha1 import ( + "fmt" + "sort" + + "github.com/redhat-appstudio/operator-toolkit/conditions" + "github.com/redhat-appstudio/release-service/metadata" tektonutils "github.com/redhat-appstudio/release-service/tekton/utils" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" ) // ReleasePlanAdmissionSpec defines the desired state of ReleasePlanAdmission. @@ -59,15 +65,28 @@ type ReleasePlanAdmissionSpec struct { ServiceAccount string `json:"serviceAccount,omitempty"` } +// MatchedReleasePlan defines the relevant information for a matched ReleasePlan. +type MatchedReleasePlan struct { + // Name contains the namespaced name of the ReleasePlan + // +kubebuilder:validation:Pattern=^[a-z0-9]([-a-z0-9]*[a-z0-9])?\/[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + // +optional + Name string `json:"name,omitempty"` + + // Active indicates whether the ReleasePlan is set to auto-release or not + // +kubebuilder:default:false + // +optional + Active bool `json:"active,omitempty"` +} + // ReleasePlanAdmissionStatus defines the observed state of ReleasePlanAdmission. type ReleasePlanAdmissionStatus struct { // Conditions represent the latest available observations for the releasePlanAdmission // +optional Conditions []metav1.Condition `json:"conditions"` - // ReleasePlan is a list of references to releasePlans matched to the ReleasePlanAdmission + // ReleasePlan is a list of releasePlans matched to the ReleasePlanAdmission // +optional - ReleasePlans []string `json:"releasePlans"` + ReleasePlans []MatchedReleasePlan `json:"releasePlans"` } // +kubebuilder:object:root=true @@ -85,6 +104,28 @@ type ReleasePlanAdmission struct { Status ReleasePlanAdmissionStatus `json:"status,omitempty"` } +// ClearMatchingInfo marks the ReleasePlanAdmission as no longer matched to any ReleasePlan. +func (rpa *ReleasePlanAdmission) ClearMatchingInfo() { + rpa.Status.ReleasePlans = []MatchedReleasePlan{} + conditions.SetCondition(&rpa.Status.Conditions, MatchedConditionType, metav1.ConditionFalse, MatchedReason) +} + +// MarkMatched marks the ReleasePlanAdmission as matched to a given ReleasePlan. +func (rpa *ReleasePlanAdmission) MarkMatched(releasePlan *ReleasePlan) { + pairedReleasePlan := MatchedReleasePlan{ + Name: fmt.Sprintf("%s%c%s", releasePlan.GetNamespace(), types.Separator, releasePlan.GetName()), + Active: (releasePlan.GetLabels()[metadata.AutoReleaseLabel] == "true"), + } + + rpa.Status.ReleasePlans = append(rpa.Status.ReleasePlans, pairedReleasePlan) + sort.Slice(rpa.Status.ReleasePlans, func(i, j int) bool { + return rpa.Status.ReleasePlans[i].Name < rpa.Status.ReleasePlans[j].Name + }) + + // Update the condition every time one is added so lastTransitionTime updates + conditions.SetCondition(&rpa.Status.Conditions, MatchedConditionType, metav1.ConditionTrue, MatchedReason) +} + // +kubebuilder:object:root=true // ReleasePlanAdmissionList contains a list of ReleasePlanAdmission. diff --git a/api/v1alpha1/releaseplanadmission_types_test.go b/api/v1alpha1/releaseplanadmission_types_test.go new file mode 100644 index 00000000..c8c17a06 --- /dev/null +++ b/api/v1alpha1/releaseplanadmission_types_test.go @@ -0,0 +1,129 @@ +/* +Copyright 2023. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/redhat-appstudio/release-service/metadata" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var _ = Describe("ReleasePlanAdmission type", func() { + When("ClearMatchingInfo method is called", func() { + var releasePlan *ReleasePlan + var releasePlanAdmission *ReleasePlanAdmission + + BeforeEach(func() { + releasePlan = &ReleasePlan{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rp", + Namespace: "default", + Labels: map[string]string{ + metadata.AutoReleaseLabel: "true", + }, + }, + } + releasePlanAdmission = &ReleasePlanAdmission{} + }) + + It("should wipe all releasePlans from the status and add matched condition as false", func() { + condition := meta.FindStatusCondition(releasePlanAdmission.Status.Conditions, MatchedConditionType.String()) + Expect(condition).To(BeNil()) + releasePlanAdmission.Status.ReleasePlans = []MatchedReleasePlan{{Name: "rp"}} + + releasePlanAdmission.ClearMatchingInfo() + + Expect(releasePlanAdmission.Status.ReleasePlans).To(Equal([]MatchedReleasePlan{})) + condition = meta.FindStatusCondition(releasePlanAdmission.Status.Conditions, MatchedConditionType.String()) + Expect(condition).NotTo(BeNil()) + Expect(condition.Status).To(Equal(metav1.ConditionFalse)) + }) + + It("should switch the condition from true to false", func() { + releasePlanAdmission.MarkMatched(releasePlan) + condition := meta.FindStatusCondition(releasePlanAdmission.Status.Conditions, MatchedConditionType.String()) + Expect(condition.Status).To(Equal(metav1.ConditionTrue)) + + releasePlanAdmission.ClearMatchingInfo() + condition = meta.FindStatusCondition(releasePlanAdmission.Status.Conditions, MatchedConditionType.String()) + Expect(condition.Status).To(Equal(metav1.ConditionFalse)) + }) + }) + + When("MarkMatched method is called", func() { + var releasePlan *ReleasePlan + var releasePlanAdmission *ReleasePlanAdmission + + BeforeEach(func() { + releasePlan = &ReleasePlan{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rp", + Namespace: "default", + Labels: map[string]string{ + metadata.AutoReleaseLabel: "true", + }, + }, + } + releasePlanAdmission = &ReleasePlanAdmission{} + }) + + It("should add matched condition as true and add the releasePlan to the status", func() { + condition := meta.FindStatusCondition(releasePlanAdmission.Status.Conditions, MatchedConditionType.String()) + Expect(condition).To(BeNil()) + + releasePlanAdmission.MarkMatched(releasePlan) + + Expect(releasePlanAdmission.Status.ReleasePlans).To(Equal([]MatchedReleasePlan{{Name: "default/rp", Active: true}})) + condition = meta.FindStatusCondition(releasePlanAdmission.Status.Conditions, MatchedConditionType.String()) + Expect(condition).NotTo(BeNil()) + Expect(condition.Status).To(Equal(metav1.ConditionTrue)) + }) + + It("should switch the condition from false to true", func() { + releasePlanAdmission.ClearMatchingInfo() + condition := meta.FindStatusCondition(releasePlanAdmission.Status.Conditions, MatchedConditionType.String()) + Expect(condition.Status).To(Equal(metav1.ConditionFalse)) + + releasePlanAdmission.MarkMatched(releasePlan) + condition = meta.FindStatusCondition(releasePlanAdmission.Status.Conditions, MatchedConditionType.String()) + Expect(condition.Status).To(Equal(metav1.ConditionTrue)) + }) + + It("should store the ReleasePlans in sorted order by Name", func() { + releasePlan2 := &ReleasePlan{ + ObjectMeta: metav1.ObjectMeta{ + Name: "r", + Namespace: "default", + Labels: map[string]string{ + metadata.AutoReleaseLabel: "true", + }, + }, + } + + releasePlanAdmission.MarkMatched(releasePlan) + releasePlanAdmission.MarkMatched(releasePlan2) + + Expect(releasePlanAdmission.Status.ReleasePlans).To(Equal([]MatchedReleasePlan{ + {Name: "default/r", Active: true}, + {Name: "default/rp", Active: true}, + })) + }) + }) +}) diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 2195f650..998f02d8 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -65,6 +65,36 @@ func (in *DeploymentInfo) DeepCopy() *DeploymentInfo { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MatchedReleasePlan) DeepCopyInto(out *MatchedReleasePlan) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MatchedReleasePlan. +func (in *MatchedReleasePlan) DeepCopy() *MatchedReleasePlan { + if in == nil { + return nil + } + out := new(MatchedReleasePlan) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MatchedReleasePlanAdmission) DeepCopyInto(out *MatchedReleasePlanAdmission) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MatchedReleasePlanAdmission. +func (in *MatchedReleasePlanAdmission) DeepCopy() *MatchedReleasePlanAdmission { + if in == nil { + return nil + } + out := new(MatchedReleasePlanAdmission) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PostActionsExecutionInfo) DeepCopyInto(out *PostActionsExecutionInfo) { *out = *in @@ -298,7 +328,7 @@ func (in *ReleasePlanAdmissionStatus) DeepCopyInto(out *ReleasePlanAdmissionStat } if in.ReleasePlans != nil { in, out := &in.ReleasePlans, &out.ReleasePlans - *out = make([]string, len(*in)) + *out = make([]MatchedReleasePlan, len(*in)) copy(*out, *in) } } @@ -380,6 +410,7 @@ func (in *ReleasePlanStatus) DeepCopyInto(out *ReleasePlanStatus) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + out.ReleasePlanAdmission = in.ReleasePlanAdmission } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ReleasePlanStatus. diff --git a/cache/cache.go b/cache/cache.go index 85e6ca9b..5fdf5c6c 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -18,6 +18,7 @@ package cache import ( "context" + applicationapiv1alpha1 "github.com/redhat-appstudio/application-api/api/v1alpha1" "github.com/redhat-appstudio/release-service/api/v1alpha1" ctrl "sigs.k8s.io/controller-runtime" @@ -34,6 +35,16 @@ func SetupComponentCache(mgr ctrl.Manager) error { "spec.application", componentIndexFunc) } +// SetupReleasePlanCache adds a new index field to be able to search ReleasePlans by target. +func SetupReleasePlanCache(mgr ctrl.Manager) error { + releasePlanIndexFunc := func(obj client.Object) []string { + return []string{obj.(*v1alpha1.ReleasePlan).Spec.Target} + } + + return mgr.GetCache().IndexField(context.Background(), &v1alpha1.ReleasePlan{}, + "spec.target", releasePlanIndexFunc) +} + // SetupReleasePlanAdmissionCache adds a new index field to be able to search ReleasePlanAdmissions by origin. func SetupReleasePlanAdmissionCache(mgr ctrl.Manager) error { releasePlanAdmissionIndexFunc := func(obj client.Object) []string { diff --git a/config/crd/bases/appstudio.redhat.com_releaseplanadmissions.yaml b/config/crd/bases/appstudio.redhat.com_releaseplanadmissions.yaml index 092dd607..d1bdecdd 100644 --- a/config/crd/bases/appstudio.redhat.com_releaseplanadmissions.yaml +++ b/config/crd/bases/appstudio.redhat.com_releaseplanadmissions.yaml @@ -190,10 +190,21 @@ spec: type: object type: array releasePlans: - description: ReleasePlan is a list of references to releasePlans matched - to the ReleasePlanAdmission + description: ReleasePlan is a list of releasePlans matched to the + ReleasePlanAdmission items: - type: string + description: MatchedReleasePlan defines the relevant information + for a matched ReleasePlan. + properties: + active: + description: Active indicates whether the ReleasePlan is set + to auto-release or not + type: boolean + name: + description: Name contains the namespaced name of the ReleasePlan + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?\/[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + type: object type: array type: object type: object diff --git a/config/crd/bases/appstudio.redhat.com_releaseplans.yaml b/config/crd/bases/appstudio.redhat.com_releaseplans.yaml index b1ca6fd5..fe5f8e6e 100644 --- a/config/crd/bases/appstudio.redhat.com_releaseplans.yaml +++ b/config/crd/bases/appstudio.redhat.com_releaseplans.yaml @@ -175,10 +175,18 @@ spec: type: object type: array releasePlanAdmission: - description: ReleasePlanAdmission contains the namespaced name of - the releasePlanAdmission this ReleasePlan is matched to - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?\/[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string + description: ReleasePlanAdmission contains the information of the + releasePlanAdmission this ReleasePlan is matched to + properties: + active: + description: Active indicates whether the ReleasePlanAdmission + is set to auto-release or not + type: boolean + name: + description: Name contains the namespaced name of the releasePlanAdmission + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?\/[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + type: object type: object type: object served: true diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 15fa2508..8351dfb9 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -25,6 +25,22 @@ rules: - enterprisecontractpolicies/status verbs: - get +- apiGroups: + - appstudio.redhat.com + resources: + - releaseplanadmissions + verbs: + - get + - list + - watch +- apiGroups: + - appstudio.redhat.com + resources: + - releaseplanadmissions/status + verbs: + - get + - patch + - update - apiGroups: - appstudio.redhat.com resources: @@ -51,6 +67,18 @@ rules: - get - patch - update +- apiGroups: + - appstudio.redhat.com + resources: + - releaseplansadmissions + verbs: + - create + - delete + - get + - list + - patch + - update + - watch - apiGroups: - appstudio.redhat.com resources: diff --git a/controllers/controllers.go b/controllers/controllers.go index 88125c42..9462c037 100644 --- a/controllers/controllers.go +++ b/controllers/controllers.go @@ -20,10 +20,12 @@ import ( "github.com/redhat-appstudio/operator-toolkit/controller" "github.com/redhat-appstudio/release-service/controllers/release" "github.com/redhat-appstudio/release-service/controllers/releaseplan" + "github.com/redhat-appstudio/release-service/controllers/releaseplanadmission" ) // EnabledControllers is a slice containing references to all the controllers that have to be registered var EnabledControllers = []controller.Controller{ &release.Controller{}, &releaseplan.Controller{}, + &releaseplanadmission.Controller{}, } diff --git a/controllers/release/controller.go b/controllers/release/controller.go index 599d1414..3d849f5f 100644 --- a/controllers/release/controller.go +++ b/controllers/release/controller.go @@ -117,6 +117,8 @@ func (c *Controller) SetupCache(mgr ctrl.Manager) error { return err } + // NOTE: Both the release and releaseplan controller need this ReleasePlanAdmission cache. However, it only needs to be added + // once to the manager, so only one controller should add it. If it is removed here, it should be added to the ReleasePlan controller. if err := cache.SetupReleasePlanAdmissionCache(mgr); err != nil { return err } diff --git a/controllers/releaseplan/adapter.go b/controllers/releaseplan/adapter.go index 7bbf38c3..b63f1fff 100644 --- a/controllers/releaseplan/adapter.go +++ b/controllers/releaseplan/adapter.go @@ -18,6 +18,8 @@ package releaseplan import ( "context" + "reflect" + "github.com/go-logr/logr" "github.com/redhat-appstudio/operator-toolkit/controller" "github.com/redhat-appstudio/release-service/api/v1alpha1" @@ -74,3 +76,26 @@ func (a *adapter) EnsureOwnerReferenceIsSet() (controller.OperationResult, error return controller.ContinueProcessing() } + +// EnsureMatchingInformationIsSet is an operation that will ensure that the ReleasePlan has updated matching +// information in its status. +func (a *adapter) EnsureMatchingInformationIsSet() (controller.OperationResult, error) { + // If an error occurs getting the ReleasePlanAdmission, mark the ReleasePlan as unmatched + releasePlanAdmission, _ := a.loader.GetMatchingReleasePlanAdmission(a.ctx, a.client, a.releasePlan) + + existingReleasePlanAdmission := a.releasePlan.Status.ReleasePlanAdmission + patch := client.MergeFrom(a.releasePlan.DeepCopy()) + + if releasePlanAdmission == nil { + a.releasePlan.MarkUnmatched() + } else { + a.releasePlan.MarkMatched(releasePlanAdmission) + } + + if reflect.DeepEqual(existingReleasePlanAdmission, a.releasePlan.Status.ReleasePlanAdmission) { + // No change in matched ReleasePlanAdmission + return controller.ContinueProcessing() + } + + return controller.RequeueOnErrorOrContinue(a.client.Status().Patch(a.ctx, a.releasePlan, patch)) +} diff --git a/controllers/releaseplan/adapter_test.go b/controllers/releaseplan/adapter_test.go index e88b4c31..468de816 100644 --- a/controllers/releaseplan/adapter_test.go +++ b/controllers/releaseplan/adapter_test.go @@ -17,16 +17,21 @@ limitations under the License. package releaseplan import ( + "reflect" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" toolkit "github.com/redhat-appstudio/operator-toolkit/loader" "github.com/redhat-appstudio/release-service/api/v1alpha1" "github.com/redhat-appstudio/release-service/loader" - "reflect" + "k8s.io/apimachinery/pkg/api/meta" applicationapiv1alpha1 "github.com/redhat-appstudio/application-api/api/v1alpha1" + tektonutils "github.com/redhat-appstudio/release-service/tekton/utils" + "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" ctrl "sigs.k8s.io/controller-runtime" ) @@ -36,7 +41,8 @@ var _ = Describe("ReleasePlan adapter", Ordered, func() { createResources func() deleteResources func() - application *applicationapiv1alpha1.Application + application *applicationapiv1alpha1.Application + releasePlanAdmission *v1alpha1.ReleasePlanAdmission ) AfterAll(func() { @@ -111,6 +117,63 @@ var _ = Describe("ReleasePlan adapter", Ordered, func() { }) }) + Context("When EnsureMatchingInformationIsSet is called", func() { + var adapter *adapter + + AfterEach(func() { + _ = adapter.client.Delete(ctx, adapter.releasePlan) + }) + + BeforeEach(func() { + adapter = createReleasePlanAndAdapter() + }) + + It("should mark the ReleasePlan as unmatched if the ReleasePlanAdmission is not found", func() { + adapter.ctx = toolkit.GetMockedContext(ctx, []toolkit.MockData{ + { + ContextKey: loader.MatchedReleasePlanAdmissionContextKey, + Err: errors.NewNotFound(schema.GroupResource{}, ""), + }, + }) + + result, err := adapter.EnsureMatchingInformationIsSet() + Expect(!result.RequeueRequest && !result.CancelRequest).To(BeTrue()) + Expect(err).NotTo(HaveOccurred()) + Expect(adapter.releasePlan.Status.ReleasePlanAdmission).To(Equal(v1alpha1.MatchedReleasePlanAdmission{})) + }) + + It("should mark the ReleasePlan as matched", func() { + adapter.ctx = toolkit.GetMockedContext(ctx, []toolkit.MockData{ + { + ContextKey: loader.MatchedReleasePlanAdmissionContextKey, + Resource: releasePlanAdmission, + }, + }) + + result, err := adapter.EnsureMatchingInformationIsSet() + Expect(!result.RequeueRequest && !result.CancelRequest).To(BeTrue()) + Expect(err).NotTo(HaveOccurred()) + Expect(adapter.releasePlan.Status.ReleasePlanAdmission.Name).To(Equal( + releasePlanAdmission.Namespace + "/" + releasePlanAdmission.Name)) + }) + + It("should not update the lastTransitionTime in the condition if the matched ReleasePlanAdmission hasn't changed", func() { + adapter.ctx = toolkit.GetMockedContext(ctx, []toolkit.MockData{ + { + ContextKey: loader.MatchedReleasePlanAdmissionContextKey, + Resource: releasePlanAdmission, + }, + }) + + adapter.EnsureMatchingInformationIsSet() + condition := meta.FindStatusCondition(adapter.releasePlan.Status.Conditions, "Matched") + lastTransitionTime := condition.LastTransitionTime + adapter.EnsureMatchingInformationIsSet() + condition = meta.FindStatusCondition(adapter.releasePlan.Status.Conditions, "Matched") + Expect(condition.LastTransitionTime).To(Equal(lastTransitionTime)) + }) + }) + createReleasePlanAndAdapter = func() *adapter { releasePlan := &v1alpha1.ReleasePlan{ ObjectMeta: metav1.ObjectMeta{ @@ -139,10 +202,32 @@ var _ = Describe("ReleasePlan adapter", Ordered, func() { }, } Expect(k8sClient.Create(ctx, application)).To(Succeed()) + + releasePlanAdmission = &v1alpha1.ReleasePlanAdmission{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rpa", + Namespace: "default", + }, + Spec: v1alpha1.ReleasePlanAdmissionSpec{ + Applications: []string{application.Name}, + Origin: "default", + Policy: "policy", + PipelineRef: &tektonutils.PipelineRef{ + Resolver: "bundles", + Params: []tektonutils.Param{ + {Name: "bundle", Value: "quay.io/some/bundle"}, + {Name: "name", Value: "release-pipeline"}, + {Name: "kind", Value: "pipeline"}, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, releasePlanAdmission)).To(Succeed()) } deleteResources = func() { Expect(k8sClient.Delete(ctx, application)).To(Succeed()) + Expect(k8sClient.Delete(ctx, releasePlanAdmission)).To(Succeed()) } }) diff --git a/controllers/releaseplan/controller.go b/controllers/releaseplan/controller.go index 9809821b..bfdbc0ac 100644 --- a/controllers/releaseplan/controller.go +++ b/controllers/releaseplan/controller.go @@ -18,11 +18,14 @@ package releaseplan import ( "context" + "github.com/redhat-appstudio/operator-toolkit/controller" "sigs.k8s.io/controller-runtime/pkg/cluster" "github.com/go-logr/logr" "github.com/redhat-appstudio/release-service/api/v1alpha1" + "github.com/redhat-appstudio/release-service/controllers/utils/handlers" + "github.com/redhat-appstudio/release-service/controllers/utils/predicates" "github.com/redhat-appstudio/release-service/loader" "k8s.io/apimachinery/pkg/api/errors" ctrl "sigs.k8s.io/controller-runtime" @@ -37,6 +40,7 @@ type Controller struct { log logr.Logger } +//+kubebuilder:rbac:groups=appstudio.redhat.com,resources=releaseplanadmissions,verbs=get;list;watch //+kubebuilder:rbac:groups=appstudio.redhat.com,resources=releaseplans,verbs=get;list;watch;create;update;patch;delete //+kubebuilder:rbac:groups=appstudio.redhat.com,resources=releaseplans/status,verbs=get;update;patch //+kubebuilder:rbac:groups=appstudio.redhat.com,resources=releaseplans/finalizers,verbs=update @@ -60,15 +64,25 @@ func (c *Controller) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu return controller.ReconcileHandler([]controller.Operation{ adapter.EnsureOwnerReferenceIsSet, + adapter.EnsureMatchingInformationIsSet, }) } // Register registers the controller with the passed manager and log. func (c *Controller) Register(mgr ctrl.Manager, log *logr.Logger, _ cluster.Cluster) error { c.client = mgr.GetClient() - c.log = log.WithName("releasePlan") return ctrl.NewControllerManagedBy(mgr). - For(&v1alpha1.ReleasePlan{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). + For(&v1alpha1.ReleasePlan{}, builder.WithPredicates(predicate.GenerationChangedPredicate{}, predicates.MatchPredicate())). + Watches(&v1alpha1.ReleasePlanAdmission{}, &handlers.EnqueueRequestForMatchedResource{}, + builder.WithPredicates(predicates.MatchPredicate())). Complete(c) } + +// SetupCache indexes fields for each of the resources used in the releaseplan adapter in those cases where filtering by +// field is required. +// NOTE: Both the release and releaseplan controller need this ReleasePlanAdmission cache. However, it only needs to be added +// once to the manager, so only one controller should add it. If it is removed from the Release controller, it should be added here. +func (c *Controller) SetupCache(mgr ctrl.Manager) error { + return nil +} diff --git a/controllers/releaseplanadmission/adapter.go b/controllers/releaseplanadmission/adapter.go new file mode 100644 index 00000000..89ed7085 --- /dev/null +++ b/controllers/releaseplanadmission/adapter.go @@ -0,0 +1,72 @@ +/* +Copyright 2023. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package releaseplanadmission + +import ( + "context" + "reflect" + + "github.com/go-logr/logr" + "github.com/redhat-appstudio/operator-toolkit/controller" + "github.com/redhat-appstudio/release-service/api/v1alpha1" + "github.com/redhat-appstudio/release-service/loader" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// adapter holds the objects needed to reconcile a ReleasePlanAdmission. +type adapter struct { + client client.Client + ctx context.Context + loader loader.ObjectLoader + logger *logr.Logger + releasePlanAdmission *v1alpha1.ReleasePlanAdmission +} + +// newAdapter creates and returns an adapter instance. +func newAdapter(ctx context.Context, client client.Client, releasePlanAdmission *v1alpha1.ReleasePlanAdmission, loader loader.ObjectLoader, logger *logr.Logger) *adapter { + return &adapter{ + client: client, + ctx: ctx, + loader: loader, + logger: logger, + releasePlanAdmission: releasePlanAdmission, + } +} + +// EnsureMatchingInformationIsSet is an operation that will ensure that the ReleasePlanAdmission has updated matching +// information in its status. +func (a *adapter) EnsureMatchingInformationIsSet() (controller.OperationResult, error) { + releasePlans, err := a.loader.GetMatchingReleasePlans(a.ctx, a.client, a.releasePlanAdmission) + if err != nil { + return controller.RequeueWithError(err) + } + + copiedReleasePlanAdmission := a.releasePlanAdmission.DeepCopy() + patch := client.MergeFrom(copiedReleasePlanAdmission) + + a.releasePlanAdmission.ClearMatchingInfo() + for i := range releasePlans.Items { + a.releasePlanAdmission.MarkMatched(&releasePlans.Items[i]) + } + + if reflect.DeepEqual(copiedReleasePlanAdmission.Status.ReleasePlans, a.releasePlanAdmission.Status.ReleasePlans) { + // No change in matched ReleasePlans + return controller.ContinueProcessing() + } + + return controller.RequeueOnErrorOrContinue(a.client.Status().Patch(a.ctx, a.releasePlanAdmission, patch)) +} diff --git a/controllers/releaseplanadmission/adapter_test.go b/controllers/releaseplanadmission/adapter_test.go new file mode 100644 index 00000000..ff1406f7 --- /dev/null +++ b/controllers/releaseplanadmission/adapter_test.go @@ -0,0 +1,248 @@ +/* +Copyright 2023. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package releaseplanadmission + +import ( + "fmt" + "reflect" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + toolkit "github.com/redhat-appstudio/operator-toolkit/loader" + "github.com/redhat-appstudio/release-service/api/v1alpha1" + "github.com/redhat-appstudio/release-service/loader" + "github.com/redhat-appstudio/release-service/metadata" + tektonutils "github.com/redhat-appstudio/release-service/tekton/utils" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" +) + +var _ = Describe("ReleasePlanAdmission adapter", Ordered, func() { + var ( + createReleasePlanAdmissionAndAdapter func() *adapter + createResources func() + deleteResources func() + + releasePlan *v1alpha1.ReleasePlan + ) + + AfterAll(func() { + deleteResources() + }) + + BeforeAll(func() { + createResources() + }) + + Context("When newAdapter is called", func() { + It("creates and return a new adapter", func() { + Expect(reflect.TypeOf(newAdapter(ctx, k8sClient, nil, loader.NewLoader(), &ctrl.Log))).To(Equal(reflect.TypeOf(&adapter{}))) + }) + }) + + Context("When EnsureMatchingInformationIsSet is called", func() { + var adapter *adapter + + AfterEach(func() { + _ = adapter.client.Delete(ctx, adapter.releasePlanAdmission) + }) + + BeforeEach(func() { + adapter = createReleasePlanAdmissionAndAdapter() + }) + + It("should RequeueWithError if error occurs when looking for ReleasePlans", func() { + adapter.ctx = toolkit.GetMockedContext(ctx, []toolkit.MockData{ + { + ContextKey: loader.MatchedReleasePlansContextKey, + Err: fmt.Errorf("some error"), + }, + }) + + result, err := adapter.EnsureMatchingInformationIsSet() + Expect(result.RequeueRequest && !result.CancelRequest).To(BeTrue()) + Expect(err).To(HaveOccurred()) + Expect(adapter.releasePlanAdmission.Status.ReleasePlans).To(BeNil()) + }) + + It("should mark the ReleasePlanAdmission as unmatched if no ReleasePlans are found", func() { + adapter.releasePlanAdmission.Status.ReleasePlans = []v1alpha1.MatchedReleasePlan{{Name: "foo"}} + adapter.ctx = toolkit.GetMockedContext(ctx, []toolkit.MockData{ + { + ContextKey: loader.MatchedReleasePlansContextKey, + Resource: &v1alpha1.ReleasePlanList{}, + }, + }) + + result, err := adapter.EnsureMatchingInformationIsSet() + Expect(!result.RequeueRequest && !result.CancelRequest).To(BeTrue()) + Expect(err).NotTo(HaveOccurred()) + Expect(adapter.releasePlanAdmission.Status.ReleasePlans).To(Equal([]v1alpha1.MatchedReleasePlan{})) + for i := range adapter.releasePlanAdmission.Status.Conditions { + if adapter.releasePlanAdmission.Status.Conditions[i].Type == "Matched" { + Expect(adapter.releasePlanAdmission.Status.Conditions[i].Status).To(Equal(metav1.ConditionFalse)) + } + } + }) + + It("should mark the ReleasePlanAdmission as matched if ReleasePlans are found", func() { + secondReleasePlan := releasePlan.DeepCopy() + secondReleasePlan.Name = "rp-two" + adapter.ctx = toolkit.GetMockedContext(ctx, []toolkit.MockData{ + { + ContextKey: loader.MatchedReleasePlansContextKey, + Resource: &v1alpha1.ReleasePlanList{ + Items: []v1alpha1.ReleasePlan{ + *releasePlan, + *secondReleasePlan, + }, + }, + }, + }) + + result, err := adapter.EnsureMatchingInformationIsSet() + Expect(!result.RequeueRequest && !result.CancelRequest).To(BeTrue()) + Expect(err).NotTo(HaveOccurred()) + Expect(adapter.releasePlanAdmission.Status.ReleasePlans).To(HaveLen(2)) + for i := range adapter.releasePlanAdmission.Status.Conditions { + if adapter.releasePlanAdmission.Status.Conditions[i].Type == "Matched" { + Expect(adapter.releasePlanAdmission.Status.Conditions[i].Status).To(Equal(metav1.ConditionTrue)) + } + } + }) + + It("should overwrite previously matched ReleasePlans", func() { + adapter.releasePlanAdmission.Status.ReleasePlans = []v1alpha1.MatchedReleasePlan{ + {Name: "rp-two", Active: false}, + {Name: "foo", Active: false}, + } + secondReleasePlan := releasePlan.DeepCopy() + secondReleasePlan.Name = "rp-two" + + adapter.ctx = toolkit.GetMockedContext(ctx, []toolkit.MockData{ + { + ContextKey: loader.MatchedReleasePlansContextKey, + Resource: &v1alpha1.ReleasePlanList{ + Items: []v1alpha1.ReleasePlan{ + *releasePlan, + *secondReleasePlan, + }, + }, + }, + }) + + result, err := adapter.EnsureMatchingInformationIsSet() + Expect(!result.RequeueRequest && !result.CancelRequest).To(BeTrue()) + Expect(err).NotTo(HaveOccurred()) + Expect(adapter.releasePlanAdmission.Status.ReleasePlans).To(HaveLen(2)) + for i := range adapter.releasePlanAdmission.Status.Conditions { + if adapter.releasePlanAdmission.Status.Conditions[i].Type == "Matched" { + Expect(adapter.releasePlanAdmission.Status.Conditions[i].Status).To(Equal(metav1.ConditionTrue)) + } + } + for _, releasePlan := range adapter.releasePlanAdmission.Status.ReleasePlans { + Expect(releasePlan.Name).NotTo(Equal("foo")) + } + }) + + It("should update the condition time if only the auto-release label on a ReleasePlan changes", func() { + testReleasePlan := releasePlan.DeepCopy() + testReleasePlan.Labels = map[string]string{} + adapter.ctx = toolkit.GetMockedContext(ctx, []toolkit.MockData{ + { + ContextKey: loader.MatchedReleasePlansContextKey, + Resource: &v1alpha1.ReleasePlanList{ + Items: []v1alpha1.ReleasePlan{ + *testReleasePlan, + }, + }, + }, + }) + + adapter.EnsureMatchingInformationIsSet() + condition := meta.FindStatusCondition(adapter.releasePlanAdmission.Status.Conditions, "Matched") + lastTransitionTime := condition.LastTransitionTime + time.Sleep(1 * time.Second) + + testReleasePlan.Labels[metadata.AutoReleaseLabel] = "true" + adapter.ctx = toolkit.GetMockedContext(ctx, []toolkit.MockData{ + { + ContextKey: loader.MatchedReleasePlansContextKey, + Resource: &v1alpha1.ReleasePlanList{ + Items: []v1alpha1.ReleasePlan{ + *testReleasePlan, + }, + }, + }, + }) + + adapter.EnsureMatchingInformationIsSet() + condition = meta.FindStatusCondition(adapter.releasePlanAdmission.Status.Conditions, "Matched") + Expect(condition.LastTransitionTime).NotTo(Equal(lastTransitionTime)) + Expect(adapter.releasePlanAdmission.Status.ReleasePlans).To(HaveLen(1)) + Expect(adapter.releasePlanAdmission.Status.ReleasePlans[0].Active).To(BeTrue()) + }) + }) + + createReleasePlanAdmissionAndAdapter = func() *adapter { + releasePlanAdmission := &v1alpha1.ReleasePlanAdmission{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rpa", + Namespace: "default", + }, + Spec: v1alpha1.ReleasePlanAdmissionSpec{ + Applications: []string{"application"}, + Origin: "default", + Policy: "policy", + PipelineRef: &tektonutils.PipelineRef{ + Resolver: "bundles", + Params: []tektonutils.Param{ + {Name: "bundle", Value: "quay.io/some/bundle"}, + {Name: "name", Value: "release-pipeline"}, + {Name: "kind", Value: "pipeline"}, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, releasePlanAdmission)).To(Succeed()) + releasePlan.Kind = "ReleasePlanAdmission" + + return newAdapter(ctx, k8sClient, releasePlanAdmission, loader.NewMockLoader(), &ctrl.Log) + } + + createResources = func() { + releasePlan = &v1alpha1.ReleasePlan{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "releaseplan-", + Namespace: "default", + }, + Spec: v1alpha1.ReleasePlanSpec{ + Application: "application", + Target: "default", + }, + } + Expect(k8sClient.Create(ctx, releasePlan)).To(Succeed()) + } + + deleteResources = func() { + Expect(k8sClient.Delete(ctx, releasePlan)).To(Succeed()) + } + +}) diff --git a/controllers/releaseplanadmission/controller.go b/controllers/releaseplanadmission/controller.go new file mode 100644 index 00000000..871ff3c8 --- /dev/null +++ b/controllers/releaseplanadmission/controller.go @@ -0,0 +1,84 @@ +/* +Copyright 2023. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package releaseplanadmission + +import ( + "context" + + "github.com/redhat-appstudio/operator-toolkit/controller" + "sigs.k8s.io/controller-runtime/pkg/cluster" + + "github.com/go-logr/logr" + "github.com/redhat-appstudio/release-service/api/v1alpha1" + "github.com/redhat-appstudio/release-service/cache" + "github.com/redhat-appstudio/release-service/controllers/utils/handlers" + "github.com/redhat-appstudio/release-service/controllers/utils/predicates" + "github.com/redhat-appstudio/release-service/loader" + "k8s.io/apimachinery/pkg/api/errors" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Controller reconciles a ReleasePlanAdmission object +type Controller struct { + client client.Client + log logr.Logger +} + +//+kubebuilder:rbac:groups=appstudio.redhat.com,resources=releaseplans,verbs=get;list;watch +//+kubebuilder:rbac:groups=appstudio.redhat.com,resources=releaseplansadmissions,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=appstudio.redhat.com,resources=releaseplanadmissions/status,verbs=get;update;patch + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +func (c *Controller) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := c.log.WithValues("ReleasePlanAdmission", req.NamespacedName) + + releasePlanAdmission := &v1alpha1.ReleasePlanAdmission{} + err := c.client.Get(ctx, req.NamespacedName, releasePlanAdmission) + if err != nil { + if errors.IsNotFound(err) { + return ctrl.Result{}, nil + } + + return ctrl.Result{}, err + } + + adapter := newAdapter(ctx, c.client, releasePlanAdmission, loader.NewLoader(), &logger) + + return controller.ReconcileHandler([]controller.Operation{ + adapter.EnsureMatchingInformationIsSet, + }) +} + +// Register registers the controller with the passed manager and log. +func (c *Controller) Register(mgr ctrl.Manager, log *logr.Logger, _ cluster.Cluster) error { + c.client = mgr.GetClient() + + return ctrl.NewControllerManagedBy(mgr). + For(&v1alpha1.ReleasePlanAdmission{}, builder.WithPredicates(predicates.MatchPredicate())). + Watches(&v1alpha1.ReleasePlan{}, &handlers.EnqueueRequestForMatchedResource{}, + builder.WithPredicates(predicates.MatchPredicate())). + Complete(c) +} + +// SetupCache indexes fields for each of the resources used in the releaseplanadmission adapter in those cases where filtering by +// field is required. +func (c *Controller) SetupCache(mgr ctrl.Manager) error { + return cache.SetupReleasePlanCache(mgr) +} diff --git a/controllers/releaseplanadmission/controller_test.go b/controllers/releaseplanadmission/controller_test.go new file mode 100644 index 00000000..ee20a272 --- /dev/null +++ b/controllers/releaseplanadmission/controller_test.go @@ -0,0 +1,73 @@ +/* +Copyright 2023. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package releaseplanadmission + +import ( + "reflect" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/metrics/server" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +var _ = Describe("ReleasePlanAdmission Controller", Ordered, func() { + // For the Reconcile function test we don't want to make a successful call as it will call every single operation + // defined there. We don't have any control over the operations being executed, and we want to keep a clean env for + // the adapter tests. + When("Reconcile is called", func() { + It("should succeed even if the releasePlanAdmission is not found", func() { + controller := &Controller{ + client: k8sClient, + log: ctrl.Log, + } + + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Name: "non-existent", + Namespace: "default", + }, + } + result, err := controller.Reconcile(ctx, req) + Expect(reflect.TypeOf(result)).To(Equal(reflect.TypeOf(reconcile.Result{}))) + Expect(err).To(BeNil()) + }) + }) + + When("Register is called", func() { + It("should setup the controller successfully", func() { + controller := &Controller{ + client: k8sClient, + log: ctrl.Log, + } + + mgr, _ := ctrl.NewManager(cfg, ctrl.Options{ + Scheme: scheme.Scheme, + Metrics: server.Options{ + BindAddress: "0", // disables metrics + }, + LeaderElection: false, + }) + Expect(controller.Register(mgr, &ctrl.Log, nil)).To(Succeed()) + }) + }) + +}) diff --git a/controllers/releaseplanadmission/suite_test.go b/controllers/releaseplanadmission/suite_test.go new file mode 100644 index 00000000..5e876703 --- /dev/null +++ b/controllers/releaseplanadmission/suite_test.go @@ -0,0 +1,92 @@ +/* +Copyright 2023. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package releaseplanadmission + +import ( + "context" + "path/filepath" + "testing" + + "k8s.io/client-go/rest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + appstudiov1alpha1 "github.com/redhat-appstudio/release-service/api/v1alpha1" + "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" +) + +var ( + cfg *rest.Config + k8sClient client.Client + testEnv *envtest.Environment + ctx context.Context + cancel context.CancelFunc +) + +func Test(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "ReleasePlanAdmission Controller Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + ctx, cancel = context.WithCancel(context.TODO()) + + // add required CRDs + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{ + filepath.Join("..", "..", "config", "crd", "bases"), + }, + ErrorIfCRDPathMissing: true, + } + + var err error + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + Expect(appstudiov1alpha1.AddToScheme(scheme.Scheme)).To(Succeed()) + + k8sManager, _ := ctrl.NewManager(cfg, ctrl.Options{ + Scheme: scheme.Scheme, + Metrics: server.Options{ + BindAddress: "0", // disables metrics + }, + LeaderElection: false, + }) + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + go func() { + defer GinkgoRecover() + Expect(k8sManager.Start(ctx)).To(Succeed()) + }() +}) + +var _ = AfterSuite(func() { + cancel() + By("tearing down the test environment") + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) +}) diff --git a/controllers/utils/handlers/enqueue_matched.go b/controllers/utils/handlers/enqueue_matched.go new file mode 100644 index 00000000..5a9f20d2 --- /dev/null +++ b/controllers/utils/handlers/enqueue_matched.go @@ -0,0 +1,95 @@ +/* +Copyright 2023. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package handlers + +import ( + "context" + "strings" + + "github.com/redhat-appstudio/release-service/api/v1alpha1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/workqueue" + "sigs.k8s.io/controller-runtime/pkg/event" + crtHandler "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +// EnqueueRequestForMatchedResource enqueues Request containing the Name and Namespace of the resource(s) specified in the +// Status of the ReleasePlans and ReleasePlanAdmissions that are the source of the Event. The source of the event +// triggers reconciliation of the parent resource. +type EnqueueRequestForMatchedResource struct{} + +var _ crtHandler.EventHandler = &EnqueueRequestForMatchedResource{} + +// Create implements EventHandler. +func (e *EnqueueRequestForMatchedResource) Create(_ context.Context, _ event.CreateEvent, _ workqueue.RateLimitingInterface) { + // A freshly created resource won't have any resources in its status +} + +// Update implements EventHandler. +func (e *EnqueueRequestForMatchedResource) Update(_ context.Context, updateEvent event.UpdateEvent, rateLimitingInterface workqueue.RateLimitingInterface) { + if releasePlan, ok := updateEvent.ObjectOld.(*v1alpha1.ReleasePlan); ok { + enqueueRequest(releasePlan.Status.ReleasePlanAdmission.Name, rateLimitingInterface) + } else if releasePlanAdmission, ok := updateEvent.ObjectOld.(*v1alpha1.ReleasePlanAdmission); ok { + for _, releasePlan := range releasePlanAdmission.Status.ReleasePlans { + enqueueRequest(releasePlan.Name, rateLimitingInterface) + } + } + + if releasePlan, ok := updateEvent.ObjectNew.(*v1alpha1.ReleasePlan); ok { + enqueueRequest(releasePlan.Status.ReleasePlanAdmission.Name, rateLimitingInterface) + } else if releasePlanAdmission, ok := updateEvent.ObjectNew.(*v1alpha1.ReleasePlanAdmission); ok { + for _, releasePlan := range releasePlanAdmission.Status.ReleasePlans { + enqueueRequest(releasePlan.Name, rateLimitingInterface) + } + } +} + +// Delete implements EventHandler. +func (e *EnqueueRequestForMatchedResource) Delete(_ context.Context, deleteEvent event.DeleteEvent, rateLimitingInterface workqueue.RateLimitingInterface) { + if releasePlan, ok := deleteEvent.Object.(*v1alpha1.ReleasePlan); ok { + enqueueRequest(releasePlan.Status.ReleasePlanAdmission.Name, rateLimitingInterface) + } else if releasePlanAdmission, ok := deleteEvent.Object.(*v1alpha1.ReleasePlanAdmission); ok { + for _, releasePlan := range releasePlanAdmission.Status.ReleasePlans { + enqueueRequest(releasePlan.Name, rateLimitingInterface) + } + } +} + +// Generic implements EventHandler. +func (e *EnqueueRequestForMatchedResource) Generic(_ context.Context, genericEvent event.GenericEvent, rateLimitingInterface workqueue.RateLimitingInterface) { + if releasePlan, ok := genericEvent.Object.(*v1alpha1.ReleasePlan); ok { + enqueueRequest(releasePlan.Status.ReleasePlanAdmission.Name, rateLimitingInterface) + } else if releasePlanAdmission, ok := genericEvent.Object.(*v1alpha1.ReleasePlanAdmission); ok { + for _, releasePlan := range releasePlanAdmission.Status.ReleasePlans { + enqueueRequest(releasePlan.Name, rateLimitingInterface) + } + } +} + +// enqueueRequest parses the provided string to extract the namespace and name into a +// types.NamespacedName and adds a request to the RateLimitingInterface with it. +func enqueueRequest(namespacedNameString string, rateLimitingInterface workqueue.RateLimitingInterface) { + values := strings.SplitN(namespacedNameString, "/", 2) + + if len(values) < 2 { + return + } + + namespacedName := types.NamespacedName{Namespace: values[0], Name: values[1]} + rateLimitingInterface.Add(reconcile.Request{NamespacedName: namespacedName}) +} diff --git a/controllers/utils/handlers/enqueue_matched_test.go b/controllers/utils/handlers/enqueue_matched_test.go new file mode 100644 index 00000000..70db8db2 --- /dev/null +++ b/controllers/utils/handlers/enqueue_matched_test.go @@ -0,0 +1,218 @@ +/* +Copyright 2023. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package handlers + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/redhat-appstudio/release-service/api/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/controller/controllertest" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "k8s.io/client-go/util/workqueue" +) + +var _ = Describe("EnqueueRequestForMatchedResource", func() { + var ctx = context.TODO() + + var rateLimitingInterface workqueue.RateLimitingInterface + var instance EnqueueRequestForMatchedResource + var releasePlan *v1alpha1.ReleasePlan + var releasePlanAdmission *v1alpha1.ReleasePlanAdmission + + BeforeEach(func() { + rateLimitingInterface = &controllertest.Queue{Interface: workqueue.New()} + releasePlan = &v1alpha1.ReleasePlan{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "rp", + }, + Spec: v1alpha1.ReleasePlanSpec{ + Application: "app", + Target: "default", + }, + Status: v1alpha1.ReleasePlanStatus{ + ReleasePlanAdmission: v1alpha1.MatchedReleasePlanAdmission{ + Name: "default/rpa", + }, + }, + } + releasePlanAdmission = &v1alpha1.ReleasePlanAdmission{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "rpa", + }, + Spec: v1alpha1.ReleasePlanAdmissionSpec{ + Applications: []string{"app"}, + Origin: "default", + }, + Status: v1alpha1.ReleasePlanAdmissionStatus{ + ReleasePlans: []v1alpha1.MatchedReleasePlan{ + {Name: "default/rp"}, + }, + }, + } + instance = EnqueueRequestForMatchedResource{} + }) + + When("A CreateEvent occurs", func() { + It("should not enqueue a request for a ReleasePlan", func() { + createEvent := event.CreateEvent{ + Object: releasePlan, + } + + instance.Create(ctx, createEvent, rateLimitingInterface) + Expect(rateLimitingInterface.Len()).To(Equal(0)) + }) + + It("should not enqueue a request for a ReleasePlanAdmission", func() { + createEvent := event.CreateEvent{ + Object: releasePlanAdmission, + } + + instance.Create(ctx, createEvent, rateLimitingInterface) + Expect(rateLimitingInterface.Len()).To(Equal(0)) + }) + }) + + When("A UpdateEvent occurs", func() { + It("should enqueue a request for both the objectOld and objectNew with ReleasePlans", func() { + newReleasePlan := releasePlan.DeepCopy() + newReleasePlan.Status.ReleasePlanAdmission.Name = "default/new-rpa" + + updateEvent := event.UpdateEvent{ + ObjectOld: releasePlan, + ObjectNew: newReleasePlan, + } + + instance.Update(ctx, updateEvent, rateLimitingInterface) + Expect(rateLimitingInterface.Len()).To(Equal(2)) + }) + + It("should enqueue a request for both the objectOld and objectNew with ReleasePlanAdmissions", func() { + newReleasePlanAdmission := releasePlanAdmission.DeepCopy() + newReleasePlanAdmission.Status.ReleasePlans = []v1alpha1.MatchedReleasePlan{ + {Name: "default/new-rp"}, + } + + updateEvent := event.UpdateEvent{ + ObjectOld: releasePlanAdmission, + ObjectNew: newReleasePlanAdmission, + } + + instance.Update(ctx, updateEvent, rateLimitingInterface) + Expect(rateLimitingInterface.Len()).To(Equal(2)) + }) + }) + + When("A DeleteEvent occurs", func() { + It("should enqueue a request for a ReleasePlan", func() { + deleteEvent := event.DeleteEvent{ + Object: releasePlan, + } + + instance.Delete(ctx, deleteEvent, rateLimitingInterface) + Expect(rateLimitingInterface.Len()).To(Equal(1)) + + i, _ := rateLimitingInterface.Get() + Expect(i).To(Equal(reconcile.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "default", + Name: "rpa", + }, + })) + }) + + It("should enqueue a request for a ReleasePlanAdmission", func() { + deleteEvent := event.DeleteEvent{ + Object: releasePlanAdmission, + } + + instance.Delete(ctx, deleteEvent, rateLimitingInterface) + Expect(rateLimitingInterface.Len()).To(Equal(1)) + + i, _ := rateLimitingInterface.Get() + Expect(i).To(Equal(reconcile.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "default", + Name: "rp", + }, + })) + }) + }) + + When("A GenericEvent occurs", func() { + It("should enqueue a request for a ReleasePlan", func() { + genericEvent := event.GenericEvent{ + Object: releasePlan, + } + + instance.Generic(ctx, genericEvent, rateLimitingInterface) + Expect(rateLimitingInterface.Len()).To(Equal(1)) + + i, _ := rateLimitingInterface.Get() + Expect(i).To(Equal(reconcile.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "default", + Name: "rpa", + }, + })) + }) + + It("should enqueue a request for a ReleasePlanAdmission", func() { + genericEvent := event.GenericEvent{ + Object: releasePlanAdmission, + } + + instance.Generic(ctx, genericEvent, rateLimitingInterface) + Expect(rateLimitingInterface.Len()).To(Equal(1)) + + i, _ := rateLimitingInterface.Get() + Expect(i).To(Equal(reconcile.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "default", + Name: "rp", + }, + })) + }) + }) + + When("enqueueRequest is called", func() { + It("should enqueue a request for a proper namespaced name", func() { + enqueueRequest("foo/bar", rateLimitingInterface) + Expect(rateLimitingInterface.Len()).To(Equal(1)) + + i, _ := rateLimitingInterface.Get() + Expect(i).To(Equal(reconcile.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "foo", + Name: "bar", + }, + })) + }) + + It("should not enqueue a request for an invalid namespaced name", func() { + enqueueRequest("bar", rateLimitingInterface) + Expect(rateLimitingInterface.Len()).To(Equal(0)) + }) + }) +}) diff --git a/controllers/utils/handlers/suite_test.go b/controllers/utils/handlers/suite_test.go new file mode 100644 index 00000000..83bbe38b --- /dev/null +++ b/controllers/utils/handlers/suite_test.go @@ -0,0 +1,55 @@ +/* +Copyright 2023 Red Hat Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package handlers + +import ( + "context" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + appstudiov1alpha1 "github.com/redhat-appstudio/release-service/api/v1alpha1" + clientsetscheme "k8s.io/client-go/kubernetes/scheme" + + logf "sigs.k8s.io/controller-runtime/pkg/log" + //+kubebuilder:scaffold:imports +) + +var ( + ctx context.Context + cancel context.CancelFunc +) + +func Test(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Handlers Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + ctx, cancel = context.WithCancel(context.TODO()) + + err := appstudiov1alpha1.AddToScheme(clientsetscheme.Scheme) + Expect(err).NotTo(HaveOccurred()) +}) + +var _ = AfterSuite(func() { + cancel() +}) diff --git a/controllers/utils/predicates/predicates.go b/controllers/utils/predicates/predicates.go new file mode 100644 index 00000000..882e160a --- /dev/null +++ b/controllers/utils/predicates/predicates.go @@ -0,0 +1,132 @@ +/* +Copyright 2023. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package predicates + +import ( + "reflect" + + "github.com/redhat-appstudio/release-service/api/v1alpha1" + "github.com/redhat-appstudio/release-service/metadata" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/predicate" +) + +// MatchPredicate returns a predicate which returns true when a ReleasePlan or ReleasePlanAdmission +// is created, deleted, or when the auto-release label, target, application, or the matched +// resource of one changes. +func MatchPredicate() predicate.Predicate { + return predicate.Funcs{ + CreateFunc: func(createEvent event.CreateEvent) bool { + return true + }, + DeleteFunc: func(deleteEvent event.DeleteEvent) bool { + return true + }, + GenericFunc: func(genericEvent event.GenericEvent) bool { + return false + }, + UpdateFunc: func(e event.UpdateEvent) bool { + return haveApplicationsChanged(e.ObjectOld, e.ObjectNew) || + hasAutoReleaseLabelChanged(e.ObjectOld, e.ObjectNew) || + hasMatchConditionChanged(e.ObjectOld, e.ObjectNew) || + hasSourceChanged(e.ObjectOld, e.ObjectNew) + }, + } +} + +// hasConditionChanged returns true if one, but not both, of the conditions +// are nil or if both are not nil and have different lastTransitionTimes. +func hasConditionChanged(conditionOld, conditionNew *metav1.Condition) bool { + if conditionOld == nil || conditionNew == nil { + return conditionOld != conditionNew + } + // both not nil, check lastTransitionTime for equality + return !conditionOld.LastTransitionTime.Equal(&conditionNew.LastTransitionTime) +} + +// hasAutoReleaseLabelChanged returns true if the auto-release label value is +// different between the two objects. +func hasAutoReleaseLabelChanged(objectOld, objectNew client.Object) bool { + return objectOld.GetLabels()[metadata.AutoReleaseLabel] != objectNew.GetLabels()[metadata.AutoReleaseLabel] +} + +// haveApplicationsChanged returns true if passed objects are of the same kind and the +// Spec.Application(s) values between them is different. +func haveApplicationsChanged(objectOld, objectNew client.Object) bool { + if releasePlanOld, ok := objectOld.(*v1alpha1.ReleasePlan); ok { + if releasePlanNew, ok := objectNew.(*v1alpha1.ReleasePlan); ok { + return releasePlanOld.Spec.Application != releasePlanNew.Spec.Application + } + } + + if releasePlanAdmissionOld, ok := objectOld.(*v1alpha1.ReleasePlanAdmission); ok { + if releasePlanAdmissionNew, ok := objectNew.(*v1alpha1.ReleasePlanAdmission); ok { + return !reflect.DeepEqual( + releasePlanAdmissionOld.Spec.Applications, + releasePlanAdmissionNew.Spec.Applications, + ) + } + } + + return false +} + +// hasMatchConditionChanged returns true if the lastTransitionTime of the Matched condition +// is different between the two objects or if one (but not both) of the objects is missing +// the Matched condition. +func hasMatchConditionChanged(objectOld, objectNew client.Object) bool { + if releasePlanOld, ok := objectOld.(*v1alpha1.ReleasePlan); ok { + if releasePlanNew, ok := objectNew.(*v1alpha1.ReleasePlan); ok { + oldCondition := meta.FindStatusCondition(releasePlanOld.Status.Conditions, + v1alpha1.MatchedConditionType.String()) + newCondition := meta.FindStatusCondition(releasePlanNew.Status.Conditions, + v1alpha1.MatchedConditionType.String()) + return hasConditionChanged(oldCondition, newCondition) + } + } else if releasePlanAdmissionOld, ok := objectOld.(*v1alpha1.ReleasePlanAdmission); ok { + if releasePlanAdmissionNew, ok := objectNew.(*v1alpha1.ReleasePlanAdmission); ok { + oldCondition := meta.FindStatusCondition(releasePlanAdmissionOld.Status.Conditions, + v1alpha1.MatchedConditionType.String()) + newCondition := meta.FindStatusCondition(releasePlanAdmissionNew.Status.Conditions, + v1alpha1.MatchedConditionType.String()) + return hasConditionChanged(oldCondition, newCondition) + } + } + return false +} + +// hasSourceChanged returns true if the objects are ReleasePlans and the Spec.Target value is +// different between the two objects or if the objects are ReleasePlanAdmissions and the +// Spec.Origin value is different between the two. +func hasSourceChanged(objectOld, objectNew client.Object) bool { + if releasePlanOld, ok := objectOld.(*v1alpha1.ReleasePlan); ok { + if releasePlanNew, ok := objectNew.(*v1alpha1.ReleasePlan); ok { + return releasePlanOld.Spec.Target != releasePlanNew.Spec.Target + } + } + + if releasePlanAdmissionOld, ok := objectOld.(*v1alpha1.ReleasePlanAdmission); ok { + if releasePlanAdmissionNew, ok := objectNew.(*v1alpha1.ReleasePlanAdmission); ok { + return releasePlanAdmissionOld.Spec.Origin != releasePlanAdmissionNew.Spec.Origin + } + } + + return false +} diff --git a/controllers/utils/predicates/predicates_test.go b/controllers/utils/predicates/predicates_test.go new file mode 100644 index 00000000..48872250 --- /dev/null +++ b/controllers/utils/predicates/predicates_test.go @@ -0,0 +1,435 @@ +/* +Copyright 2023 Red Hat Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package predicates + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/redhat-appstudio/release-service/api/v1alpha1" + "github.com/redhat-appstudio/release-service/metadata" + tektonutils "github.com/redhat-appstudio/release-service/tekton/utils" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var _ = Describe("Predicates", Ordered, func() { + + const ( + namespace = "default" + namespace2 = "other" + applicationName = "test-application" + ) + + Context("Working with ReleasePlans and ReleasePlanAdmissions", func() { + var releasePlan, releasePlanDiffApp, releasePlanDiffLabel, + releasePlanDiffTarget, releasePlanDiffStatus *v1alpha1.ReleasePlan + var releasePlanAdmission, releasePlanAdmissionDiffApps, releasePlanAdmissionDiffOrigin, + releasePlanAdmissionDiffStatus *v1alpha1.ReleasePlanAdmission + var instance predicate.Predicate + + BeforeAll(func() { + releasePlan = &v1alpha1.ReleasePlan{ + ObjectMeta: metav1.ObjectMeta{ + Name: "releaseplan", + Namespace: namespace, + Labels: map[string]string{ + metadata.AutoReleaseLabel: "true", + }, + }, + Spec: v1alpha1.ReleasePlanSpec{ + Application: applicationName, + Target: namespace2, + }, + } + releasePlanDiffApp = &v1alpha1.ReleasePlan{ + ObjectMeta: metav1.ObjectMeta{ + Name: "releaseplan-app", + Namespace: namespace, + Labels: map[string]string{ + metadata.AutoReleaseLabel: "true", + }, + }, + Spec: v1alpha1.ReleasePlanSpec{ + Application: "diff", + Target: namespace2, + }, + } + releasePlanDiffLabel = &v1alpha1.ReleasePlan{ + ObjectMeta: metav1.ObjectMeta{ + Name: "releaseplan-label", + Namespace: namespace, + Labels: map[string]string{ + metadata.AutoReleaseLabel: "false", + }, + }, + Spec: v1alpha1.ReleasePlanSpec{ + Application: applicationName, + Target: namespace2, + }, + } + releasePlanDiffTarget = &v1alpha1.ReleasePlan{ + ObjectMeta: metav1.ObjectMeta{ + Name: "releaseplan-target", + Namespace: namespace, + Labels: map[string]string{ + metadata.AutoReleaseLabel: "true", + }, + }, + Spec: v1alpha1.ReleasePlanSpec{ + Application: applicationName, + Target: "diff", + }, + } + releasePlanDiffStatus = &v1alpha1.ReleasePlan{ + ObjectMeta: metav1.ObjectMeta{ + Name: "releaseplan-status", + Namespace: namespace, + Labels: map[string]string{ + metadata.AutoReleaseLabel: "true", + }, + }, + Spec: v1alpha1.ReleasePlanSpec{ + Application: applicationName, + Target: namespace2, + }, + } + releasePlanDiffStatus.MarkMatched(&v1alpha1.ReleasePlanAdmission{}) + + releasePlanAdmission = &v1alpha1.ReleasePlanAdmission{ + ObjectMeta: metav1.ObjectMeta{ + Name: "releaseplanadmission", + Namespace: namespace, + Labels: map[string]string{ + metadata.AutoReleaseLabel: "true", + }, + }, + Spec: v1alpha1.ReleasePlanAdmissionSpec{ + Applications: []string{ + applicationName, + }, + Origin: namespace2, + Policy: "policy", + PipelineRef: &tektonutils.PipelineRef{ + Resolver: "bundles", + Params: []tektonutils.Param{ + {Name: "bundle", Value: "quay.io/some/bundle"}, + {Name: "name", Value: "release-pipeline"}, + {Name: "kind", Value: "pipeline"}, + }, + }, + }, + } + releasePlanAdmissionDiffApps = &v1alpha1.ReleasePlanAdmission{ + ObjectMeta: metav1.ObjectMeta{ + Name: "releaseplanadmission-app", + Namespace: namespace, + Labels: map[string]string{ + metadata.AutoReleaseLabel: "true", + }, + }, + Spec: v1alpha1.ReleasePlanAdmissionSpec{ + Applications: []string{ + "diff", + }, + Origin: namespace2, + Policy: "policy", + PipelineRef: &tektonutils.PipelineRef{ + Resolver: "bundles", + Params: []tektonutils.Param{ + {Name: "bundle", Value: "quay.io/some/bundle"}, + {Name: "name", Value: "release-pipeline"}, + {Name: "kind", Value: "pipeline"}, + }, + }, + }, + } + releasePlanAdmissionDiffOrigin = &v1alpha1.ReleasePlanAdmission{ + ObjectMeta: metav1.ObjectMeta{ + Name: "releaseplanadmission-origin", + Namespace: namespace, + Labels: map[string]string{ + metadata.AutoReleaseLabel: "true", + }, + }, + Spec: v1alpha1.ReleasePlanAdmissionSpec{ + Applications: []string{ + applicationName, + }, + Origin: "diff", + Policy: "policy", + PipelineRef: &tektonutils.PipelineRef{ + Resolver: "bundles", + Params: []tektonutils.Param{ + {Name: "bundle", Value: "quay.io/some/bundle"}, + {Name: "name", Value: "release-pipeline"}, + {Name: "kind", Value: "pipeline"}, + }, + }, + }, + } + releasePlanAdmissionDiffStatus = &v1alpha1.ReleasePlanAdmission{ + ObjectMeta: metav1.ObjectMeta{ + Name: "releaseplanadmission-status", + Namespace: namespace, + Labels: map[string]string{ + metadata.AutoReleaseLabel: "true", + }, + }, + Spec: v1alpha1.ReleasePlanAdmissionSpec{ + Applications: []string{ + applicationName, + }, + Origin: namespace2, + Policy: "policy", + PipelineRef: &tektonutils.PipelineRef{ + Resolver: "bundles", + Params: []tektonutils.Param{ + {Name: "bundle", Value: "quay.io/some/bundle"}, + {Name: "name", Value: "release-pipeline"}, + {Name: "kind", Value: "pipeline"}, + }, + }, + }, + } + releasePlanAdmissionDiffStatus.MarkMatched(&v1alpha1.ReleasePlan{}) + + instance = MatchPredicate() + }) + + When("calling MatchPredicate", func() { + It("returns true for creating events", func() { + contextEvent := event.CreateEvent{ + Object: releasePlan, + } + Expect(instance.Create(contextEvent)).To(BeTrue()) + }) + + It("returns true for deleting events", func() { + contextEvent := event.DeleteEvent{ + Object: releasePlan, + } + Expect(instance.Delete(contextEvent)).To(BeTrue()) + }) + + It("should ignore generic events", func() { + contextEvent := event.GenericEvent{ + Object: releasePlan, + } + Expect(instance.Generic(contextEvent)).To(BeFalse()) + }) + + It("returns true when the application changes between ReleasePlans", func() { + contextEvent := event.UpdateEvent{ + ObjectOld: releasePlan, + ObjectNew: releasePlanDiffApp, + } + Expect(instance.Update(contextEvent)).To(BeTrue()) + }) + + It("returns true when the applications change between ReleasePlanAdmissions", func() { + contextEvent := event.UpdateEvent{ + ObjectOld: releasePlanAdmission, + ObjectNew: releasePlanAdmissionDiffApps, + } + Expect(instance.Update(contextEvent)).To(BeTrue()) + }) + + It("returns true when the auto-release label changes", func() { + contextEvent := event.UpdateEvent{ + ObjectOld: releasePlan, + ObjectNew: releasePlanDiffLabel, + } + Expect(instance.Update(contextEvent)).To(BeTrue()) + }) + + It("returns true when the target changes between ReleasePlans", func() { + contextEvent := event.UpdateEvent{ + ObjectOld: releasePlan, + ObjectNew: releasePlanDiffTarget, + } + Expect(instance.Update(contextEvent)).To(BeTrue()) + }) + + It("returns true when the origin changes between ReleasePlanAdmissions", func() { + contextEvent := event.UpdateEvent{ + ObjectOld: releasePlanAdmission, + ObjectNew: releasePlanAdmissionDiffOrigin, + } + Expect(instance.Update(contextEvent)).To(BeTrue()) + }) + + It("returns true when the matched condition in the status changes", func() { + contextEvent := event.UpdateEvent{ + ObjectOld: releasePlan, + ObjectNew: releasePlanDiffStatus, + } + Expect(instance.Update(contextEvent)).To(BeTrue()) + }) + }) + + When("calling haveApplicationsChanged", func() { + It("returns true when the application has changed between ReleasePlans", func() { + Expect(haveApplicationsChanged(releasePlan, releasePlanDiffApp)).To(BeTrue()) + }) + + It("returns true when the applications have changed between ReleasePlanAdmissions", func() { + Expect(haveApplicationsChanged(releasePlanAdmission, releasePlanAdmissionDiffApps)).To(BeTrue()) + }) + + It("returns false when the application has not changed between ReleasePlans", func() { + Expect(haveApplicationsChanged(releasePlan, releasePlanDiffTarget)).To(BeFalse()) + }) + + It("returns false when the applications have not changed between ReleasePlanAdmissions", func() { + Expect(haveApplicationsChanged(releasePlanAdmission, releasePlanAdmissionDiffOrigin)).To(BeFalse()) + }) + }) + + When("calling hasSourceChanged", func() { + It("returns true when the target has changed between ReleasePlans", func() { + Expect(hasSourceChanged(releasePlan, releasePlanDiffTarget)).To(BeTrue()) + }) + + It("returns true when the origin has changed between ReleasePlanAdmissions", func() { + Expect(hasSourceChanged(releasePlanAdmission, releasePlanAdmissionDiffOrigin)).To(BeTrue()) + }) + + It("returns false when the target has not changed between ReleasePlans", func() { + Expect(hasSourceChanged(releasePlan, releasePlanDiffApp)).To(BeFalse()) + }) + + It("returns false when the target has not changed between ReleasePlanAdmissions", func() { + Expect(hasSourceChanged(releasePlanAdmission, releasePlanAdmissionDiffApps)).To(BeFalse()) + }) + }) + + When("calling hasMatchConditionChanged", func() { + It("returns true when the ReleasePlans with differing lastTransitionTimes are passed", func() { + Expect(hasMatchConditionChanged(releasePlan, releasePlanDiffStatus)).To(BeTrue()) + }) + + It("returns true when the ReleasePlanAdmissions with differing lastTransitionTimes are passed", func() { + Expect(hasMatchConditionChanged(releasePlanAdmission, releasePlanAdmissionDiffStatus)).To(BeTrue()) + }) + + It("returns false when the ReleasePlans with the same lastTransitionTimes are passed", func() { + Expect(hasMatchConditionChanged(releasePlanDiffStatus, releasePlanDiffStatus)).To(BeFalse()) + }) + + It("returns false when the ReleasePlanAdmissions with the same lastTransitionTimes are passed", func() { + Expect(hasMatchConditionChanged(releasePlanAdmissionDiffStatus, releasePlanAdmissionDiffStatus)).To(BeFalse()) + }) + + It("returns false when objects of different types are passed", func() { + Expect(hasMatchConditionChanged(releasePlanDiffStatus, releasePlanAdmissionDiffStatus)).To(BeFalse()) + }) + }) + }) + + When("calling hasConditionChanged", func() { + It("returns false when both conditions are nil", func() { + Expect(hasConditionChanged(nil, nil)).To(BeFalse()) + }) + + It("returns true when just the first condition is nil", func() { + condition := &metav1.Condition{} + Expect(hasConditionChanged(condition, nil)).To(BeTrue()) + }) + + It("returns true when just the second condition is nil", func() { + condition := &metav1.Condition{} + Expect(hasConditionChanged(nil, condition)).To(BeTrue()) + }) + + It("returns false when the conditions have the same lastTransitionTime", func() { + transitionTime := metav1.Time{Time: time.Now()} + condition1 := &metav1.Condition{LastTransitionTime: transitionTime} + condition2 := &metav1.Condition{LastTransitionTime: transitionTime} + Expect(hasConditionChanged(condition1, condition2)).To(BeFalse()) + }) + + It("returns true when the conditions have different lastTransitionTimes", func() { + transitionTime := metav1.Time{Time: time.Now()} + condition1 := &metav1.Condition{LastTransitionTime: transitionTime} + condition2 := &metav1.Condition{LastTransitionTime: metav1.Time{Time: transitionTime.Add(time.Minute)}} + Expect(hasConditionChanged(condition1, condition2)).To(BeTrue()) + }) + }) + + When("calling hasAutoReleaseLabelChanged", func() { + var podTrue, podFalse, podMissing *corev1.Pod + + BeforeAll(func() { + podTrue = &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: nil, + Labels: map[string]string{ + metadata.AutoReleaseLabel: "true", + }, + }, + } + podFalse = &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: nil, + Labels: map[string]string{ + metadata.AutoReleaseLabel: "false", + }, + }, + } + podMissing = &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: nil, + Labels: nil, + }, + } + }) + + It("returns true when the first object has no labels", func() { + Expect(hasAutoReleaseLabelChanged(podMissing, podTrue)).To(BeTrue()) + }) + + It("returns true when the second object has no labels", func() { + Expect(hasAutoReleaseLabelChanged(podTrue, podFalse)).To(BeTrue()) + }) + + It("returns true when the first object has a true label and second has a false label", func() { + Expect(hasAutoReleaseLabelChanged(podTrue, podFalse)).To(BeTrue()) + }) + + It("returns true when the first object has a false label and second has a true label", func() { + Expect(hasAutoReleaseLabelChanged(podFalse, podTrue)).To(BeTrue()) + }) + + It("returns false when the both objects have the label set to false", func() { + Expect(hasAutoReleaseLabelChanged(podFalse, podFalse)).To(BeFalse()) + }) + + It("returns false when the both objects have the label set to true", func() { + Expect(hasAutoReleaseLabelChanged(podTrue, podTrue)).To(BeFalse()) + }) + + It("returns false when the both objects are missing the label", func() { + Expect(hasAutoReleaseLabelChanged(podMissing, podMissing)).To(BeFalse()) + }) + }) +}) diff --git a/controllers/utils/predicates/suite_test.go b/controllers/utils/predicates/suite_test.go new file mode 100644 index 00000000..50a2782c --- /dev/null +++ b/controllers/utils/predicates/suite_test.go @@ -0,0 +1,55 @@ +/* +Copyright 2023 Red Hat Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package predicates + +import ( + "context" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + appstudiov1alpha1 "github.com/redhat-appstudio/release-service/api/v1alpha1" + clientsetscheme "k8s.io/client-go/kubernetes/scheme" + + logf "sigs.k8s.io/controller-runtime/pkg/log" + //+kubebuilder:scaffold:imports +) + +var ( + ctx context.Context + cancel context.CancelFunc +) + +func Test(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Predicates Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + ctx, cancel = context.WithCancel(context.TODO()) + + err := appstudiov1alpha1.AddToScheme(clientsetscheme.Scheme) + Expect(err).NotTo(HaveOccurred()) +}) + +var _ = AfterSuite(func() { + cancel() +}) diff --git a/loader/loader.go b/loader/loader.go index c595a059..22ddd1c5 100644 --- a/loader/loader.go +++ b/loader/loader.go @@ -3,10 +3,11 @@ package loader import ( "context" "fmt" - "k8s.io/utils/strings/slices" "os" "strings" + "k8s.io/utils/strings/slices" + toolkit "github.com/redhat-appstudio/operator-toolkit/loader" ecapiv1alpha1 "github.com/enterprise-contract/enterprise-contract-controller/api/v1alpha1" @@ -28,6 +29,8 @@ type ObjectLoader interface { GetEnvironment(ctx context.Context, cli client.Client, releasePlanAdmission *v1alpha1.ReleasePlanAdmission) (*applicationapiv1alpha1.Environment, error) GetManagedApplication(ctx context.Context, cli client.Client, releasePlan *v1alpha1.ReleasePlan) (*applicationapiv1alpha1.Application, error) GetManagedApplicationComponents(ctx context.Context, cli client.Client, application *applicationapiv1alpha1.Application) ([]applicationapiv1alpha1.Component, error) + GetMatchingReleasePlanAdmission(ctx context.Context, cli client.Client, releasePlan *v1alpha1.ReleasePlan) (*v1alpha1.ReleasePlanAdmission, error) + GetMatchingReleasePlans(ctx context.Context, cli client.Client, releasePlanAdmission *v1alpha1.ReleasePlanAdmission) (*v1alpha1.ReleasePlanList, error) GetRelease(ctx context.Context, cli client.Client, name, namespace string) (*v1alpha1.Release, error) GetReleasePipelineRun(ctx context.Context, cli client.Client, release *v1alpha1.Release) (*tektonv1.PipelineRun, error) GetReleasePlan(ctx context.Context, cli client.Client, release *v1alpha1.Release) (*v1alpha1.ReleasePlan, error) @@ -48,43 +51,20 @@ func NewLoader() ObjectLoader { // 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 -// ReleasePlanAdmission is not found or the List operation fails, an error will be returned. If more than -// one matching ReleasePlanAdmission objects is found, an error will be returned. +// ReleasePlanAdmission is not found or the List operation fails, an error will be returned. func (l *loader) GetActiveReleasePlanAdmission(ctx context.Context, cli client.Client, releasePlan *v1alpha1.ReleasePlan) (*v1alpha1.ReleasePlanAdmission, error) { - releasePlanAdmissions := &v1alpha1.ReleasePlanAdmissionList{} - err := cli.List(ctx, releasePlanAdmissions, - client.InNamespace(releasePlan.Spec.Target), - client.MatchingFields{"spec.origin": releasePlan.Namespace}) + releasePlanAdmission, err := l.GetMatchingReleasePlanAdmission(ctx, cli, releasePlan) if err != nil { return nil, err } - var activeReleasePlanAdmission *v1alpha1.ReleasePlanAdmission - - for i, releasePlanAdmission := range releasePlanAdmissions.Items { - if !slices.Contains(releasePlanAdmission.Spec.Applications, releasePlan.Spec.Application) { - continue - } - - if activeReleasePlanAdmission != nil { - return nil, fmt.Errorf("multiple ReleasePlanAdmissions found with the target (%+v) for application '%s'", - releasePlan.Spec.Target, releasePlan.Spec.Application) - } - - labelValue, found := releasePlanAdmission.GetLabels()[metadata.AutoReleaseLabel] - if found && labelValue == "false" { - return nil, fmt.Errorf("found ReleasePlanAdmission '%s' with auto-release label set to false", - releasePlanAdmission.Name) - } - activeReleasePlanAdmission = &releasePlanAdmissions.Items[i] - } - - if activeReleasePlanAdmission == nil { - return nil, fmt.Errorf("no ReleasePlanAdmission found in the target (%+v) for application '%s'", - releasePlan.Spec.Target, releasePlan.Spec.Application) + labelValue, found := releasePlanAdmission.GetLabels()[metadata.AutoReleaseLabel] + if found && labelValue == "false" { + return nil, fmt.Errorf("found ReleasePlanAdmission '%s' with auto-release label set to false", + releasePlanAdmission.Name) } - return activeReleasePlanAdmission, nil + return releasePlanAdmission, nil } // GetActiveReleasePlanAdmissionFromRelease returns the ReleasePlanAdmission targeted by the ReleasePlan referenced by @@ -156,6 +136,64 @@ func (l *loader) GetManagedApplicationComponents(ctx context.Context, cli client return applicationComponents.Items, nil } +// GetMatchingReleasePlanAdmission returns the ReleasePlanAdmission targeted by the given ReleasePlan. +// If a matching ReleasePlanAdmission is not found or the List operation fails, an error will be returned. +// If more than one matching ReleasePlanAdmission objects are found, an error will be returned. +func (l *loader) GetMatchingReleasePlanAdmission(ctx context.Context, cli client.Client, releasePlan *v1alpha1.ReleasePlan) (*v1alpha1.ReleasePlanAdmission, error) { + releasePlanAdmissions := &v1alpha1.ReleasePlanAdmissionList{} + err := cli.List(ctx, releasePlanAdmissions, + client.InNamespace(releasePlan.Spec.Target), + client.MatchingFields{"spec.origin": releasePlan.Namespace}) + if err != nil { + return nil, err + } + + var foundReleasePlanAdmission *v1alpha1.ReleasePlanAdmission + + for i, releasePlanAdmission := range releasePlanAdmissions.Items { + if !slices.Contains(releasePlanAdmission.Spec.Applications, releasePlan.Spec.Application) { + continue + } + + if foundReleasePlanAdmission != nil { + return nil, fmt.Errorf("multiple ReleasePlanAdmissions found in namespace (%+s) with the origin (%+s) for application '%s'", + releasePlan.Spec.Target, releasePlan.Namespace, releasePlan.Spec.Application) + } + + foundReleasePlanAdmission = &releasePlanAdmissions.Items[i] + } + + if foundReleasePlanAdmission == nil { + return nil, fmt.Errorf("no ReleasePlanAdmission found in namespace (%+s) with the origin (%+s) for application '%s'", + releasePlan.Spec.Target, releasePlan.Namespace, releasePlan.Spec.Application) + } + + return foundReleasePlanAdmission, nil +} + +// GetMatchingReleasePlans returns a list of all ReleasePlans that target the given ReleasePlanAdmission's +// namespace, specify an application that is included in the ReleasePlanAdmission's application list, and +// are in the namespace specified by the ReleasePlanAdmission's origin. If the List operation fails, an +// error will be returned. +func (l *loader) GetMatchingReleasePlans(ctx context.Context, cli client.Client, releasePlanAdmission *v1alpha1.ReleasePlanAdmission) (*v1alpha1.ReleasePlanList, error) { + releasePlans := &v1alpha1.ReleasePlanList{} + err := cli.List(ctx, releasePlans, + client.InNamespace(releasePlanAdmission.Spec.Origin), + client.MatchingFields{"spec.target": releasePlanAdmission.Namespace}) + if err != nil { + return nil, err + } + + for i := len(releasePlans.Items) - 1; i >= 0; i-- { + if !slices.Contains(releasePlanAdmission.Spec.Applications, releasePlans.Items[i].Spec.Application) { + // Remove ReleasePlans that do not have matching applications from the list + releasePlans.Items = append(releasePlans.Items[:i], releasePlans.Items[i+1:]...) + } + } + + return releasePlans, nil +} + // GetRelease returns the Release with the given name and namespace. If the Release is not found or the Get operation // fails, an error will be returned. func (l *loader) GetRelease(ctx context.Context, cli client.Client, name, namespace string) (*v1alpha1.Release, error) { diff --git a/loader/loader_mock.go b/loader/loader_mock.go index dfe5d5fd..28a9568b 100644 --- a/loader/loader_mock.go +++ b/loader/loader_mock.go @@ -20,6 +20,8 @@ const ( EnterpriseContractConfigMapContextKey EnterpriseContractPolicyContextKey EnvironmentContextKey + MatchedReleasePlansContextKey + MatchedReleasePlanAdmissionContextKey ProcessingResourcesContextKey ReleaseContextKey ReleasePipelineRunContextKey @@ -104,6 +106,22 @@ func (l *mockLoader) GetManagedApplicationComponents(ctx context.Context, cli cl return toolkit.GetMockedResourceAndErrorFromContext(ctx, ApplicationComponentsContextKey, []applicationapiv1alpha1.Component{}) } +// GetMatchingReleasePlanAdmission returns the resource and error passed as values of the context. +func (l *mockLoader) GetMatchingReleasePlanAdmission(ctx context.Context, cli client.Client, releasePlan *v1alpha1.ReleasePlan) (*v1alpha1.ReleasePlanAdmission, error) { + if ctx.Value(MatchedReleasePlanAdmissionContextKey) == nil { + return l.loader.GetMatchingReleasePlanAdmission(ctx, cli, releasePlan) + } + return toolkit.GetMockedResourceAndErrorFromContext(ctx, MatchedReleasePlanAdmissionContextKey, &v1alpha1.ReleasePlanAdmission{}) +} + +// GetMatchingReleasePlans returns the resource and error passed as values of the context. +func (l *mockLoader) GetMatchingReleasePlans(ctx context.Context, cli client.Client, releasePlanAdmission *v1alpha1.ReleasePlanAdmission) (*v1alpha1.ReleasePlanList, error) { + if ctx.Value(MatchedReleasePlansContextKey) == nil { + return l.loader.GetMatchingReleasePlans(ctx, cli, releasePlanAdmission) + } + return toolkit.GetMockedResourceAndErrorFromContext(ctx, MatchedReleasePlansContextKey, &v1alpha1.ReleasePlanList{}) +} + // GetRelease returns the resource and error passed as values of the context. func (l *mockLoader) GetRelease(ctx context.Context, cli client.Client, name, namespace string) (*v1alpha1.Release, error) { if ctx.Value(ReleaseContextKey) == nil { diff --git a/loader/loader_mock_test.go b/loader/loader_mock_test.go index 52f0c301..f680761d 100644 --- a/loader/loader_mock_test.go +++ b/loader/loader_mock_test.go @@ -125,6 +125,36 @@ var _ = Describe("Release Adapter", Ordered, func() { }) }) + When("calling GetMatchingReleasePlanAdmission", func() { + It("returns the resource and error from the context", func() { + releasePlanAdmission := &v1alpha1.ReleasePlanAdmission{} + mockContext := toolkit.GetMockedContext(ctx, []toolkit.MockData{ + { + ContextKey: MatchedReleasePlanAdmissionContextKey, + Resource: releasePlanAdmission, + }, + }) + resource, err := loader.GetMatchingReleasePlanAdmission(mockContext, nil, nil) + Expect(resource).To(Equal(releasePlanAdmission)) + Expect(err).To(BeNil()) + }) + }) + + When("calling GetMatchingReleasePlans", func() { + It("returns the resource and error from the context", func() { + releasePlans := &v1alpha1.ReleasePlanList{} + mockContext := toolkit.GetMockedContext(ctx, []toolkit.MockData{ + { + ContextKey: MatchedReleasePlansContextKey, + Resource: releasePlans, + }, + }) + resource, err := loader.GetMatchingReleasePlans(mockContext, nil, nil) + Expect(resource).To(Equal(releasePlans)) + Expect(err).To(BeNil()) + }) + }) + When("calling GetRelease", func() { It("returns the resource and error from the context", func() { release := &v1alpha1.Release{} diff --git a/loader/loader_test.go b/loader/loader_test.go index 8c421164..1c276271 100644 --- a/loader/loader_test.go +++ b/loader/loader_test.go @@ -2,10 +2,11 @@ package loader import ( "fmt" - tektonutils "github.com/redhat-appstudio/release-service/tekton/utils" "os" "strings" + tektonutils "github.com/redhat-appstudio/release-service/tekton/utils" + ecapiv1alpha1 "github.com/enterprise-contract/enterprise-contract-controller/api/v1alpha1" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -58,42 +59,22 @@ var _ = Describe("Release Adapter", Ordered, func() { Expect(returnedObject.Name).To(Equal(releasePlanAdmission.Name)) }) - It("fails to return an active release plan admission if the target does not match", func() { - modifiedReleasePlan := releasePlan.DeepCopy() - modifiedReleasePlan.Spec.Target = "non-existent-target" - - returnedObject, err := loader.GetActiveReleasePlanAdmission(ctx, k8sClient, modifiedReleasePlan) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("no ReleasePlanAdmission found in the target")) - Expect(returnedObject).To(BeNil()) - }) - - It("fails to return an active release plan admission if multiple matches are found", func() { - newReleasePlanAdmission := releasePlanAdmission.DeepCopy() - newReleasePlanAdmission.Name = "new-release-plan-admission" - newReleasePlanAdmission.ResourceVersion = "" - Expect(k8sClient.Create(ctx, newReleasePlanAdmission)).To(Succeed()) - - Eventually(func() bool { - returnedObject, err := loader.GetActiveReleasePlanAdmission(ctx, k8sClient, releasePlan) - return returnedObject == nil && err != nil && strings.Contains(err.Error(), "multiple ReleasePlanAdmissions") - }) - - Expect(k8sClient.Delete(ctx, newReleasePlanAdmission)).To(Succeed()) - }) - It("fails to return an active release plan admission if the auto release label is set to false", func() { + // Use a new application for this test so we don't have timing issues disabledReleasePlanAdmission := releasePlanAdmission.DeepCopy() disabledReleasePlanAdmission.Labels[metadata.AutoReleaseLabel] = "false" disabledReleasePlanAdmission.Name = "disabled-release-plan-admission" + disabledReleasePlanAdmission.Spec.Applications = []string{"auto-release-test"} disabledReleasePlanAdmission.ResourceVersion = "" Expect(k8sClient.Create(ctx, disabledReleasePlanAdmission)).To(Succeed()) + releasePlan.Spec.Application = "auto-release-test" Eventually(func() bool { returnedObject, err := loader.GetActiveReleasePlanAdmission(ctx, k8sClient, releasePlan) return returnedObject == nil && err != nil && strings.Contains(err.Error(), "with auto-release label set to false") }) + releasePlan.Spec.Application = application.Name Expect(k8sClient.Delete(ctx, disabledReleasePlanAdmission)).To(Succeed()) }) }) @@ -179,6 +160,80 @@ var _ = Describe("Release Adapter", Ordered, func() { }) }) + When("calling GetMatchingReleasePlanAdmission", func() { + It("returns a release plan admission", func() { + returnedObject, err := loader.GetMatchingReleasePlanAdmission(ctx, k8sClient, releasePlan) + Expect(err).NotTo(HaveOccurred()) + Expect(returnedObject).NotTo(Equal(&v1alpha1.ReleasePlanAdmission{})) + Expect(returnedObject.Name).To(Equal(releasePlanAdmission.Name)) + }) + + It("fails to return a release plan admission if the target does not match", func() { + modifiedReleasePlan := releasePlan.DeepCopy() + modifiedReleasePlan.Spec.Target = "non-existent-target" + + returnedObject, err := loader.GetMatchingReleasePlanAdmission(ctx, k8sClient, modifiedReleasePlan) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("no ReleasePlanAdmission found in namespace")) + Expect(returnedObject).To(BeNil()) + }) + + It("fails to return a release plan admission if multiple matches are found", func() { + newReleasePlanAdmission := releasePlanAdmission.DeepCopy() + newReleasePlanAdmission.Name = "new-release-plan-admission" + newReleasePlanAdmission.ResourceVersion = "" + Expect(k8sClient.Create(ctx, newReleasePlanAdmission)).To(Succeed()) + + Eventually(func() bool { + returnedObject, err := loader.GetMatchingReleasePlanAdmission(ctx, k8sClient, releasePlan) + return returnedObject == nil && err != nil && strings.Contains(err.Error(), "multiple ReleasePlanAdmissions") + }) + + Expect(k8sClient.Delete(ctx, newReleasePlanAdmission)).To(Succeed()) + }) + }) + + When("calling GetMatchingReleasePlans", func() { + var releasePlanTwo, releasePlanDiffApp *v1alpha1.ReleasePlan + + BeforeEach(func() { + releasePlanTwo = releasePlan.DeepCopy() + releasePlanTwo.Name = "rp-two" + releasePlanTwo.ResourceVersion = "" + releasePlanDiffApp = releasePlan.DeepCopy() + releasePlanDiffApp.Name = "rp-diff" + releasePlanDiffApp.Spec.Application = "some-other-app" + releasePlanDiffApp.ResourceVersion = "" + Expect(k8sClient.Create(ctx, releasePlanTwo)).To(Succeed()) + Expect(k8sClient.Create(ctx, releasePlanDiffApp)).To(Succeed()) + }) + + AfterEach(func() { + Expect(k8sClient.Delete(ctx, releasePlanTwo)).To(Succeed()) + Expect(k8sClient.Delete(ctx, releasePlanDiffApp)).To(Succeed()) + }) + + It("returns the requested list of release plans", func() { + Eventually(func() bool { + returnedObject, err := loader.GetMatchingReleasePlans(ctx, k8sClient, releasePlanAdmission) + return returnedObject != &v1alpha1.ReleasePlanList{} && err == nil && len(returnedObject.Items) == 2 + }) + }) + + It("does not return a ReleasePlan with a different application", func() { + Eventually(func() bool { + returnedObject, err := loader.GetMatchingReleasePlans(ctx, k8sClient, releasePlanAdmission) + contains := false + for _, releasePlan := range returnedObject.Items { + if releasePlan.Spec.Application == "some-other-app" { + contains = true + } + } + return returnedObject != &v1alpha1.ReleasePlanList{} && err == nil && contains == false + }) + }) + }) + When("calling GetRelease", func() { It("returns the requested release", func() { returnedObject, err := loader.GetRelease(ctx, k8sClient, release.Name, release.Namespace) diff --git a/loader/suite_test.go b/loader/suite_test.go index 31757145..00e9e0a6 100644 --- a/loader/suite_test.go +++ b/loader/suite_test.go @@ -20,9 +20,10 @@ import ( "context" "go/build" "path/filepath" - "sigs.k8s.io/controller-runtime/pkg/metrics/server" "testing" + "sigs.k8s.io/controller-runtime/pkg/metrics/server" + ecapiv1alpha1 "github.com/enterprise-contract/enterprise-contract-controller/api/v1alpha1" applicationapiv1alpha1 "github.com/redhat-appstudio/application-api/api/v1alpha1" "github.com/redhat-appstudio/operator-toolkit/test" @@ -109,6 +110,7 @@ var _ = BeforeSuite(func() { defer GinkgoRecover() Expect(cache.SetupComponentCache(mgr)).To(Succeed()) + Expect(cache.SetupReleasePlanCache(mgr)).To(Succeed()) Expect(cache.SetupReleasePlanAdmissionCache(mgr)).To(Succeed()) Expect(cache.SetupSnapshotEnvironmentBindingCache(mgr)).To(Succeed())