diff --git a/apis/placement/v1/clusterresourceplacement_types.go b/apis/placement/v1/clusterresourceplacement_types.go index 795841e72..e4fb3a2f3 100644 --- a/apis/placement/v1/clusterresourceplacement_types.go +++ b/apis/placement/v1/clusterresourceplacement_types.go @@ -132,12 +132,16 @@ type PlacementSpec struct { // +kubebuilder:validation:Required // +kubebuilder:validation:MinItems=1 // +kubebuilder:validation:MaxItems=100 + // +kubebuilder:validation:XValidation:rule="self.all(x, !(has(x.name) && size(x.name) > 0 && has(x.labelSelector)))",message="name and labelSelector are mutually exclusive in a resource selector" ResourceSelectors []ResourceSelectorTerm `json:"resourceSelectors"` // Policy defines how to select member clusters to place the selected resources. // If unspecified, all the joined member clusters are selected. // +kubebuilder:validation:Optional // +kubebuilder:validation:XValidation:rule="!(self.placementType != oldSelf.placementType)",message="placement type is immutable" + // +kubebuilder:validation:XValidation:rule="self.placementType != 'PickFixed' || (!has(self.affinity) && !has(self.numberOfClusters) && !has(self.topologySpreadConstraints) && !has(self.tolerations))",message="affinity, numberOfClusters, topologySpreadConstraints, and tolerations cannot be set when placementType is PickFixed" + // +kubebuilder:validation:XValidation:rule="self.placementType != 'PickAll' || (!has(self.clusterNames) && !has(self.numberOfClusters) && !has(self.topologySpreadConstraints))",message="clusterNames, numberOfClusters, and topologySpreadConstraints cannot be set when placementType is PickAll" + // +kubebuilder:validation:XValidation:rule="self.placementType != 'PickN' || (!has(self.clusterNames) && has(self.numberOfClusters))",message="clusterNames cannot be set and numberOfClusters must be set when placementType is PickN" Policy *PlacementPolicy `json:"policy,omitempty"` // The rollout strategy to use to replace existing placement with new ones. @@ -341,6 +345,8 @@ type PlacementPolicy struct { // This field is beta-level and is for the taints and tolerations feature. // +kubebuilder:validation:MaxItems=100 // +kubebuilder:validation:Optional + // +kubebuilder:validation:XValidation:rule="self.all(x, x.operator != 'Exists' || !has(x.value) || size(x.value) == 0)",message="value must be empty when operator is Exists" + // +kubebuilder:validation:XValidation:rule="self.all(x, (has(x.key) && size(x.key) > 0) || x.operator == 'Exists')",message="operator must be Exists when key is empty" Tolerations []Toleration `json:"tolerations,omitempty"` } @@ -499,6 +505,7 @@ type PropertySorter struct { // SortOrder explains how Fleet should perform the sort; specifically, whether Fleet should // sort in ascending or descending order. // +kubebuilder:validation:Required + // +kubebuilder:validation:Enum=Ascending;Descending SortOrder PropertySortOrder `json:"sortOrder"` } @@ -564,6 +571,8 @@ type TopologySpreadConstraint struct { // but giving higher precedence to topologies that would help reduce the skew. // It's an optional field. // +kubebuilder:validation:Optional + // +kubebuilder:default=DoNotSchedule + // +kubebuilder:validation:Enum=DoNotSchedule;ScheduleAnyway WhenUnsatisfiable UnsatisfiableConstraintAction `json:"whenUnsatisfiable,omitempty"` } @@ -989,6 +998,7 @@ type RollingUpdateConfig struct { // Default is 60. // +kubebuilder:default=60 // +kubebuilder:validation:Optional + // +kubebuilder:validation:Minimum=0 UnavailablePeriodSeconds *int `json:"unavailablePeriodSeconds,omitempty"` } diff --git a/apis/placement/v1beta1/clusterresourceplacement_types.go b/apis/placement/v1beta1/clusterresourceplacement_types.go index 956962d5c..f571d0a5a 100644 --- a/apis/placement/v1beta1/clusterresourceplacement_types.go +++ b/apis/placement/v1beta1/clusterresourceplacement_types.go @@ -133,12 +133,16 @@ type PlacementSpec struct { // +kubebuilder:validation:Required // +kubebuilder:validation:MinItems=1 // +kubebuilder:validation:MaxItems=100 + // +kubebuilder:validation:XValidation:rule="self.all(x, !(has(x.name) && size(x.name) > 0 && has(x.labelSelector)))",message="name and labelSelector are mutually exclusive in a resource selector" ResourceSelectors []ResourceSelectorTerm `json:"resourceSelectors"` // Policy defines how to select member clusters to place the selected resources. // If unspecified, all the joined member clusters are selected. // +kubebuilder:validation:Optional // +kubebuilder:validation:XValidation:rule="!(self.placementType != oldSelf.placementType)",message="placement type is immutable" + // +kubebuilder:validation:XValidation:rule="self.placementType != 'PickFixed' || (!has(self.affinity) && !has(self.numberOfClusters) && !has(self.topologySpreadConstraints) && !has(self.tolerations))",message="affinity, numberOfClusters, topologySpreadConstraints, and tolerations cannot be set when placementType is PickFixed" + // +kubebuilder:validation:XValidation:rule="self.placementType != 'PickAll' || (!has(self.clusterNames) && !has(self.numberOfClusters) && !has(self.topologySpreadConstraints))",message="clusterNames, numberOfClusters, and topologySpreadConstraints cannot be set when placementType is PickAll" + // +kubebuilder:validation:XValidation:rule="self.placementType != 'PickN' || (!has(self.clusterNames) && has(self.numberOfClusters))",message="clusterNames cannot be set and numberOfClusters must be set when placementType is PickN" Policy *PlacementPolicy `json:"policy,omitempty"` // The rollout strategy to use to replace existing placement with new ones. @@ -349,6 +353,8 @@ type PlacementPolicy struct { // This field is beta-level and is for the taints and tolerations feature. // +kubebuilder:validation:MaxItems=100 // +kubebuilder:validation:Optional + // +kubebuilder:validation:XValidation:rule="self.all(x, x.operator != 'Exists' || !has(x.value) || size(x.value) == 0)",message="value must be empty when operator is Exists" + // +kubebuilder:validation:XValidation:rule="self.all(x, (has(x.key) && size(x.key) > 0) || x.operator == 'Exists')",message="operator must be Exists when key is empty" Tolerations []Toleration `json:"tolerations,omitempty"` } @@ -507,6 +513,7 @@ type PropertySorter struct { // SortOrder explains how Fleet should perform the sort; specifically, whether Fleet should // sort in ascending or descending order. // +kubebuilder:validation:Required + // +kubebuilder:validation:Enum=Ascending;Descending SortOrder PropertySortOrder `json:"sortOrder"` } @@ -572,6 +579,8 @@ type TopologySpreadConstraint struct { // but giving higher precedence to topologies that would help reduce the skew. // It's an optional field. // +kubebuilder:validation:Optional + // +kubebuilder:default=DoNotSchedule + // +kubebuilder:validation:Enum=DoNotSchedule;ScheduleAnyway WhenUnsatisfiable UnsatisfiableConstraintAction `json:"whenUnsatisfiable,omitempty"` } @@ -1004,6 +1013,7 @@ type RollingUpdateConfig struct { // Default is 60. // +kubebuilder:default=60 // +kubebuilder:validation:Optional + // +kubebuilder:validation:Minimum=0 UnavailablePeriodSeconds *int `json:"unavailablePeriodSeconds,omitempty"` } diff --git a/config/crd/bases/placement.kubernetes-fleet.io_clusterresourceoverrides.yaml b/config/crd/bases/placement.kubernetes-fleet.io_clusterresourceoverrides.yaml index f3f4cdfef..b6fd06d50 100644 --- a/config/crd/bases/placement.kubernetes-fleet.io_clusterresourceoverrides.yaml +++ b/config/crd/bases/placement.kubernetes-fleet.io_clusterresourceoverrides.yaml @@ -342,6 +342,9 @@ spec: description: |- SortOrder explains how Fleet should perform the sort; specifically, whether Fleet should sort in ascending or descending order. + enum: + - Ascending + - Descending type: string required: - name @@ -746,6 +749,9 @@ spec: description: |- SortOrder explains how Fleet should perform the sort; specifically, whether Fleet should sort in ascending or descending order. + enum: + - Ascending + - Descending type: string required: - name @@ -1146,6 +1152,9 @@ spec: description: |- SortOrder explains how Fleet should perform the sort; specifically, whether Fleet should sort in ascending or descending order. + enum: + - Ascending + - Descending type: string required: - name diff --git a/config/crd/bases/placement.kubernetes-fleet.io_clusterresourceoverridesnapshots.yaml b/config/crd/bases/placement.kubernetes-fleet.io_clusterresourceoverridesnapshots.yaml index a57b5b3d2..5bbe5d5bd 100644 --- a/config/crd/bases/placement.kubernetes-fleet.io_clusterresourceoverridesnapshots.yaml +++ b/config/crd/bases/placement.kubernetes-fleet.io_clusterresourceoverridesnapshots.yaml @@ -356,6 +356,9 @@ spec: description: |- SortOrder explains how Fleet should perform the sort; specifically, whether Fleet should sort in ascending or descending order. + enum: + - Ascending + - Descending type: string required: - name @@ -774,6 +777,9 @@ spec: description: |- SortOrder explains how Fleet should perform the sort; specifically, whether Fleet should sort in ascending or descending order. + enum: + - Ascending + - Descending type: string required: - name @@ -1188,6 +1194,9 @@ spec: description: |- SortOrder explains how Fleet should perform the sort; specifically, whether Fleet should sort in ascending or descending order. + enum: + - Ascending + - Descending type: string required: - name diff --git a/config/crd/bases/placement.kubernetes-fleet.io_clusterresourceplacements.yaml b/config/crd/bases/placement.kubernetes-fleet.io_clusterresourceplacements.yaml index 5c586555d..7740c8c48 100644 --- a/config/crd/bases/placement.kubernetes-fleet.io_clusterresourceplacements.yaml +++ b/config/crd/bases/placement.kubernetes-fleet.io_clusterresourceplacements.yaml @@ -244,6 +244,9 @@ spec: description: |- SortOrder explains how Fleet should perform the sort; specifically, whether Fleet should sort in ascending or descending order. + enum: + - Ascending + - Descending type: string required: - name @@ -403,6 +406,9 @@ spec: description: |- SortOrder explains how Fleet should perform the sort; specifically, whether Fleet should sort in ascending or descending order. + enum: + - Ascending + - Descending type: string required: - name @@ -481,6 +487,13 @@ spec: type: object maxItems: 100 type: array + x-kubernetes-validations: + - message: value must be empty when operator is Exists + rule: self.all(x, x.operator != 'Exists' || !has(x.value) || + size(x.value) == 0) + - message: operator must be Exists when key is empty + rule: self.all(x, (has(x.key) && size(x.key) > 0) || x.operator + == 'Exists') topologySpreadConstraints: description: |- TopologySpreadConstraints describes how a group of resources ought to spread across multiple topology @@ -513,6 +526,7 @@ spec: It's a required field. type: string whenUnsatisfiable: + default: DoNotSchedule description: |- WhenUnsatisfiable indicates how to deal with the resource if it doesn't satisfy the spread constraint. @@ -520,6 +534,9 @@ spec: - ScheduleAnyway tells the scheduler to schedule the resource in any cluster, but giving higher precedence to topologies that would help reduce the skew. It's an optional field. + enum: + - DoNotSchedule + - ScheduleAnyway type: string required: - topologyKey @@ -529,6 +546,19 @@ spec: x-kubernetes-validations: - message: placement type is immutable rule: '!(self.placementType != oldSelf.placementType)' + - message: affinity, numberOfClusters, topologySpreadConstraints, + and tolerations cannot be set when placementType is PickFixed + rule: self.placementType != 'PickFixed' || (!has(self.affinity) + && !has(self.numberOfClusters) && !has(self.topologySpreadConstraints) + && !has(self.tolerations)) + - message: clusterNames, numberOfClusters, and topologySpreadConstraints + cannot be set when placementType is PickAll + rule: self.placementType != 'PickAll' || (!has(self.clusterNames) + && !has(self.numberOfClusters) && !has(self.topologySpreadConstraints)) + - message: clusterNames cannot be set and numberOfClusters must be + set when placementType is PickN + rule: self.placementType != 'PickN' || (!has(self.clusterNames) + && has(self.numberOfClusters)) resourceSelectors: description: |- ResourceSelectors is an array of selectors used to select cluster scoped resources. The selectors are `ORed`. @@ -646,6 +676,10 @@ spec: maxItems: 100 minItems: 1 type: array + x-kubernetes-validations: + - message: name and labelSelector are mutually exclusive in a resource + selector + rule: self.all(x, !(has(x.name) && size(x.name) > 0 && has(x.labelSelector))) revisionHistoryLimit: default: 10 description: |- @@ -951,6 +985,7 @@ spec: For other types of resources, we consider them as available after `UnavailablePeriodSeconds` seconds have passed since they were successfully applied to the target cluster. Default is 60. + minimum: 0 type: integer type: object type: @@ -1823,6 +1858,9 @@ spec: description: |- SortOrder explains how Fleet should perform the sort; specifically, whether Fleet should sort in ascending or descending order. + enum: + - Ascending + - Descending type: string required: - name @@ -1982,6 +2020,9 @@ spec: description: |- SortOrder explains how Fleet should perform the sort; specifically, whether Fleet should sort in ascending or descending order. + enum: + - Ascending + - Descending type: string required: - name @@ -2060,6 +2101,13 @@ spec: type: object maxItems: 100 type: array + x-kubernetes-validations: + - message: value must be empty when operator is Exists + rule: self.all(x, x.operator != 'Exists' || !has(x.value) || + size(x.value) == 0) + - message: operator must be Exists when key is empty + rule: self.all(x, (has(x.key) && size(x.key) > 0) || x.operator + == 'Exists') topologySpreadConstraints: description: |- TopologySpreadConstraints describes how a group of resources ought to spread across multiple topology @@ -2092,6 +2140,7 @@ spec: It's a required field. type: string whenUnsatisfiable: + default: DoNotSchedule description: |- WhenUnsatisfiable indicates how to deal with the resource if it doesn't satisfy the spread constraint. @@ -2099,6 +2148,9 @@ spec: - ScheduleAnyway tells the scheduler to schedule the resource in any cluster, but giving higher precedence to topologies that would help reduce the skew. It's an optional field. + enum: + - DoNotSchedule + - ScheduleAnyway type: string required: - topologyKey @@ -2108,6 +2160,19 @@ spec: x-kubernetes-validations: - message: placement type is immutable rule: '!(self.placementType != oldSelf.placementType)' + - message: affinity, numberOfClusters, topologySpreadConstraints, + and tolerations cannot be set when placementType is PickFixed + rule: self.placementType != 'PickFixed' || (!has(self.affinity) + && !has(self.numberOfClusters) && !has(self.topologySpreadConstraints) + && !has(self.tolerations)) + - message: clusterNames, numberOfClusters, and topologySpreadConstraints + cannot be set when placementType is PickAll + rule: self.placementType != 'PickAll' || (!has(self.clusterNames) + && !has(self.numberOfClusters) && !has(self.topologySpreadConstraints)) + - message: clusterNames cannot be set and numberOfClusters must be + set when placementType is PickN + rule: self.placementType != 'PickN' || (!has(self.clusterNames) + && has(self.numberOfClusters)) resourceSelectors: description: |- ResourceSelectors is an array of selectors used to select cluster scoped resources. The selectors are `ORed`. @@ -2224,6 +2289,10 @@ spec: maxItems: 100 minItems: 1 type: array + x-kubernetes-validations: + - message: name and labelSelector are mutually exclusive in a resource + selector + rule: self.all(x, !(has(x.name) && size(x.name) > 0 && has(x.labelSelector))) revisionHistoryLimit: default: 10 description: |- @@ -2595,6 +2664,7 @@ spec: For other types of resources, we consider them as available after `UnavailablePeriodSeconds` seconds have passed since they were successfully applied to the target cluster. Default is 60. + minimum: 0 type: integer type: object type: diff --git a/config/crd/bases/placement.kubernetes-fleet.io_clusterschedulingpolicysnapshots.yaml b/config/crd/bases/placement.kubernetes-fleet.io_clusterschedulingpolicysnapshots.yaml index 954a72bab..f2c32be53 100644 --- a/config/crd/bases/placement.kubernetes-fleet.io_clusterschedulingpolicysnapshots.yaml +++ b/config/crd/bases/placement.kubernetes-fleet.io_clusterschedulingpolicysnapshots.yaml @@ -216,6 +216,9 @@ spec: description: |- SortOrder explains how Fleet should perform the sort; specifically, whether Fleet should sort in ascending or descending order. + enum: + - Ascending + - Descending type: string required: - name @@ -375,6 +378,9 @@ spec: description: |- SortOrder explains how Fleet should perform the sort; specifically, whether Fleet should sort in ascending or descending order. + enum: + - Ascending + - Descending type: string required: - name @@ -453,6 +459,13 @@ spec: type: object maxItems: 100 type: array + x-kubernetes-validations: + - message: value must be empty when operator is Exists + rule: self.all(x, x.operator != 'Exists' || !has(x.value) || + size(x.value) == 0) + - message: operator must be Exists when key is empty + rule: self.all(x, (has(x.key) && size(x.key) > 0) || x.operator + == 'Exists') topologySpreadConstraints: description: |- TopologySpreadConstraints describes how a group of resources ought to spread across multiple topology @@ -485,6 +498,7 @@ spec: It's a required field. type: string whenUnsatisfiable: + default: DoNotSchedule description: |- WhenUnsatisfiable indicates how to deal with the resource if it doesn't satisfy the spread constraint. @@ -492,6 +506,9 @@ spec: - ScheduleAnyway tells the scheduler to schedule the resource in any cluster, but giving higher precedence to topologies that would help reduce the skew. It's an optional field. + enum: + - DoNotSchedule + - ScheduleAnyway type: string required: - topologyKey @@ -832,6 +849,9 @@ spec: description: |- SortOrder explains how Fleet should perform the sort; specifically, whether Fleet should sort in ascending or descending order. + enum: + - Ascending + - Descending type: string required: - name @@ -991,6 +1011,9 @@ spec: description: |- SortOrder explains how Fleet should perform the sort; specifically, whether Fleet should sort in ascending or descending order. + enum: + - Ascending + - Descending type: string required: - name @@ -1069,6 +1092,13 @@ spec: type: object maxItems: 100 type: array + x-kubernetes-validations: + - message: value must be empty when operator is Exists + rule: self.all(x, x.operator != 'Exists' || !has(x.value) || + size(x.value) == 0) + - message: operator must be Exists when key is empty + rule: self.all(x, (has(x.key) && size(x.key) > 0) || x.operator + == 'Exists') topologySpreadConstraints: description: |- TopologySpreadConstraints describes how a group of resources ought to spread across multiple topology @@ -1101,6 +1131,7 @@ spec: It's a required field. type: string whenUnsatisfiable: + default: DoNotSchedule description: |- WhenUnsatisfiable indicates how to deal with the resource if it doesn't satisfy the spread constraint. @@ -1108,6 +1139,9 @@ spec: - ScheduleAnyway tells the scheduler to schedule the resource in any cluster, but giving higher precedence to topologies that would help reduce the skew. It's an optional field. + enum: + - DoNotSchedule + - ScheduleAnyway type: string required: - topologyKey diff --git a/config/crd/bases/placement.kubernetes-fleet.io_resourceoverrides.yaml b/config/crd/bases/placement.kubernetes-fleet.io_resourceoverrides.yaml index a7e469370..3f062f0a5 100644 --- a/config/crd/bases/placement.kubernetes-fleet.io_resourceoverrides.yaml +++ b/config/crd/bases/placement.kubernetes-fleet.io_resourceoverrides.yaml @@ -222,6 +222,9 @@ spec: description: |- SortOrder explains how Fleet should perform the sort; specifically, whether Fleet should sort in ascending or descending order. + enum: + - Ascending + - Descending type: string required: - name @@ -536,6 +539,9 @@ spec: description: |- SortOrder explains how Fleet should perform the sort; specifically, whether Fleet should sort in ascending or descending order. + enum: + - Ascending + - Descending type: string required: - name @@ -846,6 +852,9 @@ spec: description: |- SortOrder explains how Fleet should perform the sort; specifically, whether Fleet should sort in ascending or descending order. + enum: + - Ascending + - Descending type: string required: - name diff --git a/config/crd/bases/placement.kubernetes-fleet.io_resourceoverridesnapshots.yaml b/config/crd/bases/placement.kubernetes-fleet.io_resourceoverridesnapshots.yaml index 31114d670..156371e8d 100644 --- a/config/crd/bases/placement.kubernetes-fleet.io_resourceoverridesnapshots.yaml +++ b/config/crd/bases/placement.kubernetes-fleet.io_resourceoverridesnapshots.yaml @@ -236,6 +236,9 @@ spec: description: |- SortOrder explains how Fleet should perform the sort; specifically, whether Fleet should sort in ascending or descending order. + enum: + - Ascending + - Descending type: string required: - name @@ -568,6 +571,9 @@ spec: description: |- SortOrder explains how Fleet should perform the sort; specifically, whether Fleet should sort in ascending or descending order. + enum: + - Ascending + - Descending type: string required: - name @@ -896,6 +902,9 @@ spec: description: |- SortOrder explains how Fleet should perform the sort; specifically, whether Fleet should sort in ascending or descending order. + enum: + - Ascending + - Descending type: string required: - name diff --git a/config/crd/bases/placement.kubernetes-fleet.io_resourceplacements.yaml b/config/crd/bases/placement.kubernetes-fleet.io_resourceplacements.yaml index db0cdea5d..ee3855bce 100644 --- a/config/crd/bases/placement.kubernetes-fleet.io_resourceplacements.yaml +++ b/config/crd/bases/placement.kubernetes-fleet.io_resourceplacements.yaml @@ -236,6 +236,9 @@ spec: description: |- SortOrder explains how Fleet should perform the sort; specifically, whether Fleet should sort in ascending or descending order. + enum: + - Ascending + - Descending type: string required: - name @@ -395,6 +398,9 @@ spec: description: |- SortOrder explains how Fleet should perform the sort; specifically, whether Fleet should sort in ascending or descending order. + enum: + - Ascending + - Descending type: string required: - name @@ -473,6 +479,13 @@ spec: type: object maxItems: 100 type: array + x-kubernetes-validations: + - message: value must be empty when operator is Exists + rule: self.all(x, x.operator != 'Exists' || !has(x.value) || + size(x.value) == 0) + - message: operator must be Exists when key is empty + rule: self.all(x, (has(x.key) && size(x.key) > 0) || x.operator + == 'Exists') topologySpreadConstraints: description: |- TopologySpreadConstraints describes how a group of resources ought to spread across multiple topology @@ -505,6 +518,7 @@ spec: It's a required field. type: string whenUnsatisfiable: + default: DoNotSchedule description: |- WhenUnsatisfiable indicates how to deal with the resource if it doesn't satisfy the spread constraint. @@ -512,6 +526,9 @@ spec: - ScheduleAnyway tells the scheduler to schedule the resource in any cluster, but giving higher precedence to topologies that would help reduce the skew. It's an optional field. + enum: + - DoNotSchedule + - ScheduleAnyway type: string required: - topologyKey @@ -521,6 +538,19 @@ spec: x-kubernetes-validations: - message: placement type is immutable rule: '!(self.placementType != oldSelf.placementType)' + - message: affinity, numberOfClusters, topologySpreadConstraints, + and tolerations cannot be set when placementType is PickFixed + rule: self.placementType != 'PickFixed' || (!has(self.affinity) + && !has(self.numberOfClusters) && !has(self.topologySpreadConstraints) + && !has(self.tolerations)) + - message: clusterNames, numberOfClusters, and topologySpreadConstraints + cannot be set when placementType is PickAll + rule: self.placementType != 'PickAll' || (!has(self.clusterNames) + && !has(self.numberOfClusters) && !has(self.topologySpreadConstraints)) + - message: clusterNames cannot be set and numberOfClusters must be + set when placementType is PickN + rule: self.placementType != 'PickN' || (!has(self.clusterNames) + && has(self.numberOfClusters)) resourceSelectors: description: |- ResourceSelectors is an array of selectors used to select cluster scoped resources. The selectors are `ORed`. @@ -638,6 +668,10 @@ spec: maxItems: 100 minItems: 1 type: array + x-kubernetes-validations: + - message: name and labelSelector are mutually exclusive in a resource + selector + rule: self.all(x, !(has(x.name) && size(x.name) > 0 && has(x.labelSelector))) revisionHistoryLimit: default: 10 description: |- @@ -943,6 +977,7 @@ spec: For other types of resources, we consider them as available after `UnavailablePeriodSeconds` seconds have passed since they were successfully applied to the target cluster. Default is 60. + minimum: 0 type: integer type: object type: @@ -1800,6 +1835,9 @@ spec: description: |- SortOrder explains how Fleet should perform the sort; specifically, whether Fleet should sort in ascending or descending order. + enum: + - Ascending + - Descending type: string required: - name @@ -1959,6 +1997,9 @@ spec: description: |- SortOrder explains how Fleet should perform the sort; specifically, whether Fleet should sort in ascending or descending order. + enum: + - Ascending + - Descending type: string required: - name @@ -2037,6 +2078,13 @@ spec: type: object maxItems: 100 type: array + x-kubernetes-validations: + - message: value must be empty when operator is Exists + rule: self.all(x, x.operator != 'Exists' || !has(x.value) || + size(x.value) == 0) + - message: operator must be Exists when key is empty + rule: self.all(x, (has(x.key) && size(x.key) > 0) || x.operator + == 'Exists') topologySpreadConstraints: description: |- TopologySpreadConstraints describes how a group of resources ought to spread across multiple topology @@ -2069,6 +2117,7 @@ spec: It's a required field. type: string whenUnsatisfiable: + default: DoNotSchedule description: |- WhenUnsatisfiable indicates how to deal with the resource if it doesn't satisfy the spread constraint. @@ -2076,6 +2125,9 @@ spec: - ScheduleAnyway tells the scheduler to schedule the resource in any cluster, but giving higher precedence to topologies that would help reduce the skew. It's an optional field. + enum: + - DoNotSchedule + - ScheduleAnyway type: string required: - topologyKey @@ -2085,6 +2137,19 @@ spec: x-kubernetes-validations: - message: placement type is immutable rule: '!(self.placementType != oldSelf.placementType)' + - message: affinity, numberOfClusters, topologySpreadConstraints, + and tolerations cannot be set when placementType is PickFixed + rule: self.placementType != 'PickFixed' || (!has(self.affinity) + && !has(self.numberOfClusters) && !has(self.topologySpreadConstraints) + && !has(self.tolerations)) + - message: clusterNames, numberOfClusters, and topologySpreadConstraints + cannot be set when placementType is PickAll + rule: self.placementType != 'PickAll' || (!has(self.clusterNames) + && !has(self.numberOfClusters) && !has(self.topologySpreadConstraints)) + - message: clusterNames cannot be set and numberOfClusters must be + set when placementType is PickN + rule: self.placementType != 'PickN' || (!has(self.clusterNames) + && has(self.numberOfClusters)) resourceSelectors: description: |- ResourceSelectors is an array of selectors used to select cluster scoped resources. The selectors are `ORed`. @@ -2201,6 +2266,10 @@ spec: maxItems: 100 minItems: 1 type: array + x-kubernetes-validations: + - message: name and labelSelector are mutually exclusive in a resource + selector + rule: self.all(x, !(has(x.name) && size(x.name) > 0 && has(x.labelSelector))) revisionHistoryLimit: default: 10 description: |- @@ -2572,6 +2641,7 @@ spec: For other types of resources, we consider them as available after `UnavailablePeriodSeconds` seconds have passed since they were successfully applied to the target cluster. Default is 60. + minimum: 0 type: integer type: object type: diff --git a/config/crd/bases/placement.kubernetes-fleet.io_schedulingpolicysnapshots.yaml b/config/crd/bases/placement.kubernetes-fleet.io_schedulingpolicysnapshots.yaml index 0485e0696..f613bb965 100644 --- a/config/crd/bases/placement.kubernetes-fleet.io_schedulingpolicysnapshots.yaml +++ b/config/crd/bases/placement.kubernetes-fleet.io_schedulingpolicysnapshots.yaml @@ -216,6 +216,9 @@ spec: description: |- SortOrder explains how Fleet should perform the sort; specifically, whether Fleet should sort in ascending or descending order. + enum: + - Ascending + - Descending type: string required: - name @@ -375,6 +378,9 @@ spec: description: |- SortOrder explains how Fleet should perform the sort; specifically, whether Fleet should sort in ascending or descending order. + enum: + - Ascending + - Descending type: string required: - name @@ -453,6 +459,13 @@ spec: type: object maxItems: 100 type: array + x-kubernetes-validations: + - message: value must be empty when operator is Exists + rule: self.all(x, x.operator != 'Exists' || !has(x.value) || + size(x.value) == 0) + - message: operator must be Exists when key is empty + rule: self.all(x, (has(x.key) && size(x.key) > 0) || x.operator + == 'Exists') topologySpreadConstraints: description: |- TopologySpreadConstraints describes how a group of resources ought to spread across multiple topology @@ -485,6 +498,7 @@ spec: It's a required field. type: string whenUnsatisfiable: + default: DoNotSchedule description: |- WhenUnsatisfiable indicates how to deal with the resource if it doesn't satisfy the spread constraint. @@ -492,6 +506,9 @@ spec: - ScheduleAnyway tells the scheduler to schedule the resource in any cluster, but giving higher precedence to topologies that would help reduce the skew. It's an optional field. + enum: + - DoNotSchedule + - ScheduleAnyway type: string required: - topologyKey diff --git a/test/apis/placement/v1beta1/api_validation_integration_test.go b/test/apis/placement/v1beta1/api_validation_integration_test.go index 7759cb5a1..04df99f65 100644 --- a/test/apis/placement/v1beta1/api_validation_integration_test.go +++ b/test/apis/placement/v1beta1/api_validation_integration_test.go @@ -24,11 +24,13 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" k8sErrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/util/intstr" + "sigs.k8s.io/controller-runtime/pkg/client" placementv1beta1 "go.goms.io/fleet/apis/placement/v1beta1" ) @@ -49,6 +51,20 @@ const ( unknownScope = "UnknownScope" ) +const ( + nonExistentNSName = "non-existent-ns" + nonExistentCMName = "non-existent-cm" +) + +const ( + clusterName1 = "cluster-1" +) + +const ( + eventuallyDuration = 10 * time.Second + eventuallyInterval = 500 * time.Millisecond +) + // createValidClusterResourceOverride creates a valid ClusterResourceOverride for testing purposes. // The placement parameter is optional - pass nil for no placement reference. func createValidClusterResourceOverride(name string, placement *placementv1beta1.PlacementRef) placementv1beta1.ClusterResourceOverride { @@ -121,12 +137,32 @@ func createValidResourceOverride(namespace, name string, placement *placementv1b } var _ = Describe("Test placement v1beta1 API validation", func() { - Context("Test ClusterResourcePlacement API validation - invalid cases", func() { - var crp placementv1beta1.ClusterResourcePlacement - crpName := fmt.Sprintf(crpNameTemplate, GinkgoParallelProcess()) + Context("Test ClusterResourcePlacement API validation", func() { + AfterEach(func() { + // Clean up the CRP created during each test case. + crpName := fmt.Sprintf(crpNameTemplate, GinkgoParallelProcess()) - BeforeEach(func() { - crp = placementv1beta1.ClusterResourcePlacement{ + Eventually(func() error { + crp := &placementv1beta1.ClusterResourcePlacement{ + ObjectMeta: metav1.ObjectMeta{ + Name: crpName, + }, + } + if err := hubClient.Delete(ctx, crp); err != nil && !k8sErrors.IsNotFound(err) { + return fmt.Errorf("failed to delete CRP: %w", err) + } + + if err := hubClient.Get(ctx, client.ObjectKey{Name: crpName}, &placementv1beta1.ClusterResourcePlacement{}); !k8sErrors.IsNotFound(err) { + return fmt.Errorf("CRP still exists after deletion attempt (error: %w)", err) + } + return nil + }, eventuallyDuration, eventuallyInterval).Should(Succeed()) + }) + + It("cannot set name and label selector in a resource selector at the same time", func() { + crpName := fmt.Sprintf(crpNameTemplate, GinkgoParallelProcess()) + + crp := &placementv1beta1.ClusterResourcePlacement{ ObjectMeta: metav1.ObjectMeta{ Name: crpName, }, @@ -136,60 +172,27 @@ var _ = Describe("Test placement v1beta1 API validation", func() { Group: "", Version: "v1", Kind: "Namespace", - Name: "test-ns", + Name: nonExistentNSName, + LabelSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"foo": "bar"}, + }, }, }, - Policy: &placementv1beta1.PlacementPolicy{ - PlacementType: placementv1beta1.PickFixedPlacementType, - ClusterNames: []string{"cluster1", "cluster2"}, - }, - Strategy: placementv1beta1.RolloutStrategy{ - Type: placementv1beta1.ExternalRolloutStrategyType, - }, }, } - Expect(hubClient.Create(ctx, &crp)).Should(Succeed()) - }) - - AfterEach(func() { - Expect(hubClient.Delete(ctx, &crp)).Should(Succeed()) - }) - - It("should deny update of ClusterResourcePlacement with nil policy", func() { - crp.Spec.Policy = nil - err := hubClient.Update(ctx, &crp) - var statusErr *k8sErrors.StatusError - Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Update CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) - Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("policy cannot be removed once set")) - }) - - It("should deny update of ClusterResourcePlacement with different placement type", func() { - crp.Spec.Policy.PlacementType = placementv1beta1.PickAllPlacementType - err := hubClient.Update(ctx, &crp) - var statusErr *k8sErrors.StatusError - Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Update CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) - Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("placement type is immutable")) - }) - It("should deny update of RolloutStrategy type when External", func() { - crp.Spec.Strategy.Type = placementv1beta1.RollingUpdateRolloutStrategyType - err := hubClient.Update(ctx, &crp) + err := hubClient.Create(ctx, crp) + Expect(err).To(HaveOccurred(), "Expected error when creating CRP with both name and label selector in a resource selector") var statusErr *k8sErrors.StatusError - Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Update CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) - Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("cannot change rollout strategy type from 'External' to other types")) + Expect(errors.As(err, &statusErr)).To(BeTrue(), "The returned error is not a StatusError") + Expect(statusErr.Status().Message).Should(ContainSubstring("name and labelSelector are mutually exclusive in a resource selector")) }) - }) - - Context("Test ClusterResourcePlacement StatusReportingScope validation - create, allow cases", func() { - var crp placementv1beta1.ClusterResourcePlacement - crpName := fmt.Sprintf(crpNameTemplate, GinkgoParallelProcess()) - AfterEach(func() { - Expect(hubClient.Delete(ctx, &crp)).Should(Succeed()) - }) + It("cannot set numberOfClusters when placementType is PickFixed", func() { + crpName := fmt.Sprintf(crpNameTemplate, GinkgoParallelProcess()) - It("should allow creation of ClusterResourcePlacement with StatusReportingScope NamespaceAccessible and single namespace selector", func() { - crp = placementv1beta1.ClusterResourcePlacement{ + var numberOfClusters int32 = 1 + crp := &placementv1beta1.ClusterResourcePlacement{ ObjectMeta: metav1.ObjectMeta{ Name: crpName, }, @@ -199,17 +202,28 @@ var _ = Describe("Test placement v1beta1 API validation", func() { Group: "", Version: "v1", Kind: "Namespace", - Name: "test-ns", + Name: nonExistentNSName, }, }, - StatusReportingScope: placementv1beta1.NamespaceAccessible, + Policy: &placementv1beta1.PlacementPolicy{ + PlacementType: placementv1beta1.PickFixedPlacementType, + ClusterNames: []string{clusterName1}, + NumberOfClusters: &numberOfClusters, + }, }, } - Expect(hubClient.Create(ctx, &crp)).Should(Succeed()) + + err := hubClient.Create(ctx, crp) + Expect(err).To(HaveOccurred(), "Expected error when creating CRP with numberOfClusters set for PickFixed placementType") + var statusErr *k8sErrors.StatusError + Expect(errors.As(err, &statusErr)).To(BeTrue(), "The returned error is not a StatusError") + Expect(statusErr.Status().Message).Should(ContainSubstring("affinity, numberOfClusters, topologySpreadConstraints, and tolerations cannot be set when placementType is PickFixed")) }) - It("should allow creation of ClusterResourcePlacement with StatusReportingScope NamespaceAccessible and one namespace plus other cluster-scoped resources", func() { - crp = placementv1beta1.ClusterResourcePlacement{ + It("cannot set affinity when placementType is PickFixed", func() { + crpName := fmt.Sprintf(crpNameTemplate, GinkgoParallelProcess()) + + crp := &placementv1beta1.ClusterResourcePlacement{ ObjectMeta: metav1.ObjectMeta{ Name: crpName, }, @@ -219,29 +233,40 @@ var _ = Describe("Test placement v1beta1 API validation", func() { Group: "", Version: "v1", Kind: "Namespace", - Name: "test-ns", + Name: nonExistentNSName, }, - { - Group: "rbac.authorization.k8s.io", - Version: "v1", - Kind: "ClusterRole", - Name: "test-cluster-role", - }, - { - Group: "", - Version: "v1", - Kind: "PersistentVolume", - Name: "test-pv", + }, + Policy: &placementv1beta1.PlacementPolicy{ + PlacementType: placementv1beta1.PickFixedPlacementType, + ClusterNames: []string{clusterName1}, + Affinity: &placementv1beta1.Affinity{ + ClusterAffinity: &placementv1beta1.ClusterAffinity{ + RequiredDuringSchedulingIgnoredDuringExecution: &placementv1beta1.ClusterSelector{ + ClusterSelectorTerms: []placementv1beta1.ClusterSelectorTerm{ + { + LabelSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"foo": "bar"}, + }, + }, + }, + }, + }, }, }, - StatusReportingScope: placementv1beta1.NamespaceAccessible, }, } - Expect(hubClient.Create(ctx, &crp)).Should(Succeed()) + + err := hubClient.Create(ctx, crp) + Expect(err).To(HaveOccurred(), "Expected error when creating CRP with affinity set for PickFixed placementType") + var statusErr *k8sErrors.StatusError + Expect(errors.As(err, &statusErr)).To(BeTrue(), "The returned error is not a StatusError") + Expect(statusErr.Status().Message).Should(ContainSubstring("affinity, numberOfClusters, topologySpreadConstraints, and tolerations cannot be set when placementType is PickFixed")) }) - It("should allow creation of ClusterResourcePlacement with StatusReportingScope ClusterScopeOnly and multiple namespace selector plus other cluster-scoped resources", func() { - crp = placementv1beta1.ClusterResourcePlacement{ + It("cannot set topologySpreadConstraints when placementType is PickFixed", func() { + crpName := fmt.Sprintf(crpNameTemplate, GinkgoParallelProcess()) + + crp := &placementv1beta1.ClusterResourcePlacement{ ObjectMeta: metav1.ObjectMeta{ Name: crpName, }, @@ -251,35 +276,32 @@ var _ = Describe("Test placement v1beta1 API validation", func() { Group: "", Version: "v1", Kind: "Namespace", - Name: "test-ns-1", - }, - { - Group: "", - Version: "v1", - Kind: "Namespace", - Name: "test-ns-2", - }, - { - Group: "rbac.authorization.k8s.io", - Version: "v1", - Kind: "ClusterRole", - Name: "test-cluster-role", + Name: nonExistentNSName, }, - { - Group: "", - Version: "v1", - Kind: "PersistentVolume", - Name: "test-pv", + }, + Policy: &placementv1beta1.PlacementPolicy{ + PlacementType: placementv1beta1.PickFixedPlacementType, + ClusterNames: []string{clusterName1}, + TopologySpreadConstraints: []placementv1beta1.TopologySpreadConstraint{ + { + TopologyKey: "topology.kubernetes.io/zone", + }, }, }, - StatusReportingScope: placementv1beta1.ClusterScopeOnly, }, } - Expect(hubClient.Create(ctx, &crp)).Should(Succeed()) + + err := hubClient.Create(ctx, crp) + Expect(err).To(HaveOccurred(), "Expected error when creating CRP with topologySpreadConstraints set for PickFixed placementType") + var statusErr *k8sErrors.StatusError + Expect(errors.As(err, &statusErr)).To(BeTrue(), "The returned error is not a StatusError") + Expect(statusErr.Status().Message).Should(ContainSubstring("affinity, numberOfClusters, topologySpreadConstraints, and tolerations cannot be set when placementType is PickFixed")) }) - It("should allow creation of ClusterResourcePlacement with default StatusReportingScope and multiple namespace selectors plus other cluster-scoped resources", func() { - crp = placementv1beta1.ClusterResourcePlacement{ + It("cannot set tolerations when placementType is PickFixed", func() { + crpName := fmt.Sprintf(crpNameTemplate, GinkgoParallelProcess()) + + crp := &placementv1beta1.ClusterResourcePlacement{ ObjectMeta: metav1.ObjectMeta{ Name: crpName, }, @@ -289,35 +311,35 @@ var _ = Describe("Test placement v1beta1 API validation", func() { Group: "", Version: "v1", Kind: "Namespace", - Name: "test-ns-1", - }, - { - Group: "", - Version: "v1", - Kind: "Namespace", - Name: "test-ns-2", - }, - { - Group: "rbac.authorization.k8s.io", - Version: "v1", - Kind: "ClusterRole", - Name: "test-cluster-role", + Name: nonExistentNSName, }, - { - Group: "", - Version: "v1", - Kind: "PersistentVolume", - Name: "test-pv", + }, + Policy: &placementv1beta1.PlacementPolicy{ + PlacementType: placementv1beta1.PickFixedPlacementType, + ClusterNames: []string{clusterName1}, + Tolerations: []placementv1beta1.Toleration{ + { + Key: "foo", + Operator: corev1.TolerationOpEqual, + Value: "bar", + Effect: corev1.TaintEffectNoSchedule, + }, }, }, - // StatusReportingScope not specified - should default to ClusterScopeOnly }, } - Expect(hubClient.Create(ctx, &crp)).Should(Succeed()) + + err := hubClient.Create(ctx, crp) + Expect(err).To(HaveOccurred(), "Expected error when creating CRP with tolerations set for PickFixed placementType") + var statusErr *k8sErrors.StatusError + Expect(errors.As(err, &statusErr)).To(BeTrue(), "The returned error is not a StatusError") + Expect(statusErr.Status().Message).Should(ContainSubstring("affinity, numberOfClusters, topologySpreadConstraints, and tolerations cannot be set when placementType is PickFixed")) }) - It("should allow creation of ClusterResourcePlacement with empty string as StatusReportingScope and multiple namespace selectors plus other cluster-scoped resources", func() { - crp = placementv1beta1.ClusterResourcePlacement{ + It("cannot set clusterNames when placementType is PickAll", func() { + crpName := fmt.Sprintf(crpNameTemplate, GinkgoParallelProcess()) + + crp := &placementv1beta1.ClusterResourcePlacement{ ObjectMeta: metav1.ObjectMeta{ Name: crpName, }, @@ -327,40 +349,28 @@ var _ = Describe("Test placement v1beta1 API validation", func() { Group: "", Version: "v1", Kind: "Namespace", - Name: "test-ns-1", - }, - { - Group: "", - Version: "v1", - Kind: "Namespace", - Name: "test-ns-2", - }, - { - Group: "rbac.authorization.k8s.io", - Version: "v1", - Kind: "ClusterRole", - Name: "test-cluster-role", - }, - { - Group: "", - Version: "v1", - Kind: "PersistentVolume", - Name: "test-pv", + Name: nonExistentNSName, }, }, - StatusReportingScope: "", // defaults to ClusterScopeOnly. + Policy: &placementv1beta1.PlacementPolicy{ + PlacementType: placementv1beta1.PickAllPlacementType, + ClusterNames: []string{clusterName1}, + }, }, } - Expect(hubClient.Create(ctx, &crp)).Should(Succeed()) + + err := hubClient.Create(ctx, crp) + Expect(err).To(HaveOccurred(), "Expected error when creating CRP with clusterNames set for PickAll placementType") + var statusErr *k8sErrors.StatusError + Expect(errors.As(err, &statusErr)).To(BeTrue(), "The returned error is not a StatusError") + Expect(statusErr.Status().Message).Should(ContainSubstring("clusterNames, numberOfClusters, and topologySpreadConstraints cannot be set when placementType is PickAll")) }) - }) - Context("Test ClusterResourcePlacement StatusReportingScope validation - create, deny cases", func() { - var crp placementv1beta1.ClusterResourcePlacement - crpName := fmt.Sprintf(crpNameTemplate, GinkgoParallelProcess()) + It("cannot set numberOfClusters when placementType is PickAll", func() { + crpName := fmt.Sprintf(crpNameTemplate, GinkgoParallelProcess()) - It("should deny creation of ClusterResourcePlacement with Unknown StatusReportingScope and multiple namespace selectors", func() { - crp = placementv1beta1.ClusterResourcePlacement{ + var numberOfClusters int32 = 1 + crp := &placementv1beta1.ClusterResourcePlacement{ ObjectMeta: metav1.ObjectMeta{ Name: crpName, }, @@ -370,20 +380,27 @@ var _ = Describe("Test placement v1beta1 API validation", func() { Group: "", Version: "v1", Kind: "Namespace", - Name: "test-ns-1", + Name: nonExistentNSName, }, }, - StatusReportingScope: unknownScope, // Invalid scope + Policy: &placementv1beta1.PlacementPolicy{ + PlacementType: placementv1beta1.PickAllPlacementType, + NumberOfClusters: &numberOfClusters, + }, }, } - err := hubClient.Create(ctx, &crp) + + err := hubClient.Create(ctx, crp) + Expect(err).To(HaveOccurred(), "Expected error when creating CRP with numberOfClusters set for PickAll placementType") var statusErr *k8sErrors.StatusError - Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Create CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) - Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("supported values: \"ClusterScopeOnly\", \"NamespaceAccessible\"")) + Expect(errors.As(err, &statusErr)).To(BeTrue(), "The returned error is not a StatusError") + Expect(statusErr.Status().Message).Should(ContainSubstring("clusterNames, numberOfClusters, and topologySpreadConstraints cannot be set when placementType is PickAll")) }) - It("should deny creation of ClusterResourcePlacement with StatusReportingScope NamespaceAccessible and multiple namespace selectors", func() { - crp = placementv1beta1.ClusterResourcePlacement{ + It("cannot set topologySpreadConstraints when placementType is PickAll", func() { + crpName := fmt.Sprintf(crpNameTemplate, GinkgoParallelProcess()) + + crp := &placementv1beta1.ClusterResourcePlacement{ ObjectMeta: metav1.ObjectMeta{ Name: crpName, }, @@ -393,60 +410,63 @@ var _ = Describe("Test placement v1beta1 API validation", func() { Group: "", Version: "v1", Kind: "Namespace", - Name: "test-ns-1", + Name: nonExistentNSName, }, - { - Group: "", - Version: "v1", - Kind: "Namespace", - Name: "test-ns-2", + }, + Policy: &placementv1beta1.PlacementPolicy{ + PlacementType: placementv1beta1.PickAllPlacementType, + TopologySpreadConstraints: []placementv1beta1.TopologySpreadConstraint{ + { + TopologyKey: "topology.kubernetes.io/zone", + }, }, }, - StatusReportingScope: placementv1beta1.NamespaceAccessible, }, } - err := hubClient.Create(ctx, &crp) + + err := hubClient.Create(ctx, crp) + Expect(err).To(HaveOccurred(), "Expected error when creating CRP with topologySpreadConstraints set for PickAll placementType") var statusErr *k8sErrors.StatusError - Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Create CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) - Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("when statusReportingScope is NamespaceAccessible, exactly one resourceSelector with kind 'Namespace' is required")) + Expect(errors.As(err, &statusErr)).To(BeTrue(), "The returned error is not a StatusError") + Expect(statusErr.Status().Message).Should(ContainSubstring("clusterNames, numberOfClusters, and topologySpreadConstraints cannot be set when placementType is PickAll")) }) - It("should deny creation of ClusterResourcePlacement with StatusReportingScope NamespaceAccessible and no namespace selector", func() { - crp = placementv1beta1.ClusterResourcePlacement{ + It("cannot set clusterNames when placementType is PickN", func() { + crpName := fmt.Sprintf(crpNameTemplate, GinkgoParallelProcess()) + + var numberOfClusters int32 = 1 + crp := &placementv1beta1.ClusterResourcePlacement{ ObjectMeta: metav1.ObjectMeta{ Name: crpName, }, Spec: placementv1beta1.PlacementSpec{ ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ - { - Group: "rbac.authorization.k8s.io", - Version: "v1", - Kind: "ClusterRole", - Name: "test-cluster-role", - }, { Group: "", Version: "v1", - Kind: "PersistentVolume", - Name: "test-pv", + Kind: "Namespace", + Name: nonExistentNSName, }, }, - StatusReportingScope: placementv1beta1.NamespaceAccessible, + Policy: &placementv1beta1.PlacementPolicy{ + PlacementType: placementv1beta1.PickNPlacementType, + ClusterNames: []string{clusterName1}, + NumberOfClusters: &numberOfClusters, + }, }, } - err := hubClient.Create(ctx, &crp) + + err := hubClient.Create(ctx, crp) + Expect(err).To(HaveOccurred(), "Expected error when creating CRP with clusterNames set for PickN placementType") var statusErr *k8sErrors.StatusError - Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Create CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) - Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("when statusReportingScope is NamespaceAccessible, exactly one resourceSelector with kind 'Namespace' is required")) + Expect(errors.As(err, &statusErr)).To(BeTrue(), "The returned error is not a StatusError") + Expect(statusErr.Status().Message).Should(ContainSubstring("clusterNames cannot be set and numberOfClusters must be set when placementType is PickN")) }) - }) - Context("Test ClusterResourcePlacement ClusterScopeOnly StatusReportingScope validation - update cases", func() { - var crp placementv1beta1.ClusterResourcePlacement - crpName := fmt.Sprintf(crpNameTemplate, GinkgoParallelProcess()) + It("must set numberOfClusters when placementType is PickN", func() { + crpName := fmt.Sprintf(crpNameTemplate, GinkgoParallelProcess()) - BeforeEach(func() { - crp = placementv1beta1.ClusterResourcePlacement{ + crp := &placementv1beta1.ClusterResourcePlacement{ ObjectMeta: metav1.ObjectMeta{ Name: crpName, }, @@ -456,69 +476,97 @@ var _ = Describe("Test placement v1beta1 API validation", func() { Group: "", Version: "v1", Kind: "Namespace", - Name: "test-ns-1", + Name: nonExistentNSName, }, }, - // By default, StatusReportingScope is ClusterScopeOnly + Policy: &placementv1beta1.PlacementPolicy{ + PlacementType: placementv1beta1.PickNPlacementType, + }, }, } - Expect(hubClient.Create(ctx, &crp)).Should(Succeed()) - }) - AfterEach(func() { - Expect(hubClient.Delete(ctx, &crp)).Should(Succeed()) + err := hubClient.Create(ctx, crp) + Expect(err).To(HaveOccurred(), "Expected error when creating CRP without numberOfClusters set for PickN placementType") + var statusErr *k8sErrors.StatusError + Expect(errors.As(err, &statusErr)).To(BeTrue(), "The returned error is not a StatusError") + Expect(statusErr.Status().Message).Should(ContainSubstring("clusterNames cannot be set and numberOfClusters must be set when placementType is PickN")) }) - It("should allow empty string for StatusReportingScope in a ClusterResourcePlacement when StatusReportingScope is not set", func() { - Expect(crp.Spec.StatusReportingScope).To(Equal(placementv1beta1.ClusterScopeOnly), "CRP should have default StatusReportingScope ClusterScopeOnly") - crp.Spec.StatusReportingScope = "" // Empty string should default to ClusterScopeOnly - Expect(hubClient.Update(ctx, &crp)).Should(Succeed()) - Expect(crp.Spec.StatusReportingScope).To(Equal(placementv1beta1.ClusterScopeOnly), "CRP should have default StatusReportingScope ClusterScopeOnly") - }) + It("cannot set a toleration value when its operator is Exists", func() { + crpName := fmt.Sprintf(crpNameTemplate, GinkgoParallelProcess()) - It("should allow update of ClusterResourcePlacement which has default StatusReportingScope, multiple namespace resource selectors", func() { - crp.Spec.ResourceSelectors = append(crp.Spec.ResourceSelectors, []placementv1beta1.ResourceSelectorTerm{ - { - Group: "", - Version: "v1", - Kind: "Namespace", - Name: "test-ns-2", + crp := &placementv1beta1.ClusterResourcePlacement{ + ObjectMeta: metav1.ObjectMeta{ + Name: crpName, }, - }...) - Expect(hubClient.Update(ctx, &crp)).Should(Succeed()) - }) - - It("should allow update of ClusterResourcePlacement with StatusReportingScope ClusterScopeOnly, multiple namespace resource selectors", func() { - crp.Spec.ResourceSelectors = append(crp.Spec.ResourceSelectors, []placementv1beta1.ResourceSelectorTerm{ - { - Group: "", - Version: "v1", - Kind: "Namespace", - Name: "test-ns-2", + Spec: placementv1beta1.PlacementSpec{ + ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ + { + Group: "", + Version: "v1", + Kind: "Namespace", + Name: nonExistentNSName, + }, + }, + Policy: &placementv1beta1.PlacementPolicy{ + PlacementType: placementv1beta1.PickAllPlacementType, + Tolerations: []placementv1beta1.Toleration{ + { + Key: "foo", + Operator: corev1.TolerationOpExists, + Value: "bar", + Effect: corev1.TaintEffectNoSchedule, + }, + }, + }, }, - }...) - crp.Spec.StatusReportingScope = placementv1beta1.ClusterScopeOnly - Expect(hubClient.Update(ctx, &crp)).Should(Succeed()) - }) + } - It("should deny update of ClusterResourcePlacement StatusReportingScope to NamespaceAccessible due to immutability", func() { - crp.Spec.StatusReportingScope = placementv1beta1.NamespaceAccessible - err := hubClient.Update(ctx, &crp) + err := hubClient.Create(ctx, crp) + Expect(err).To(HaveOccurred(), "Expected error when creating CRP with a toleration value set for operator Exists") var statusErr *k8sErrors.StatusError - Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Update CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) - Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("statusReportingScope is immutable")) + Expect(errors.As(err, &statusErr)).To(BeTrue(), "The returned error is not a StatusError") + Expect(statusErr.Status().Message).Should(ContainSubstring("value must be empty when operator is Exists")) }) - It("should deny update of ClusterResourcePlacement StatusReportingScope to unknown scope", func() { - crp.Spec.StatusReportingScope = unknownScope // Invalid scope - err := hubClient.Update(ctx, &crp) + It("must set a toleration operator to Exists when its key is empty", func() { + crpName := fmt.Sprintf(crpNameTemplate, GinkgoParallelProcess()) + + crp := &placementv1beta1.ClusterResourcePlacement{ + ObjectMeta: metav1.ObjectMeta{ + Name: crpName, + }, + Spec: placementv1beta1.PlacementSpec{ + ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ + { + Group: "", + Version: "v1", + Kind: "Namespace", + Name: nonExistentNSName, + }, + }, + Policy: &placementv1beta1.PlacementPolicy{ + PlacementType: placementv1beta1.PickAllPlacementType, + Tolerations: []placementv1beta1.Toleration{ + { + Operator: corev1.TolerationOpEqual, + Value: "bar", + Effect: corev1.TaintEffectNoSchedule, + }, + }, + }, + }, + } + + err := hubClient.Create(ctx, crp) + Expect(err).To(HaveOccurred(), "Expected error when creating CRP with an empty toleration key and operator Equal") var statusErr *k8sErrors.StatusError - Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Update CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) - Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("supported values: \"ClusterScopeOnly\", \"NamespaceAccessible\"")) + Expect(errors.As(err, &statusErr)).To(BeTrue(), "The returned error is not a StatusError") + Expect(statusErr.Status().Message).Should(ContainSubstring("operator must be Exists when key is empty")) }) }) - Context("Test ClusterResourcePlacement NamespaceAccessible StatusReportingScope validation - update cases", func() { + Context("Test ClusterResourcePlacement API validation - invalid update cases", func() { var crp placementv1beta1.ClusterResourcePlacement crpName := fmt.Sprintf(crpNameTemplate, GinkgoParallelProcess()) @@ -533,10 +581,16 @@ var _ = Describe("Test placement v1beta1 API validation", func() { Group: "", Version: "v1", Kind: "Namespace", - Name: "test-ns-1", + Name: "test-ns", }, }, - StatusReportingScope: placementv1beta1.NamespaceAccessible, + Policy: &placementv1beta1.PlacementPolicy{ + PlacementType: placementv1beta1.PickFixedPlacementType, + ClusterNames: []string{"cluster1", "cluster2"}, + }, + Strategy: placementv1beta1.RolloutStrategy{ + Type: placementv1beta1.ExternalRolloutStrategyType, + }, }, } Expect(hubClient.Create(ctx, &crp)).Should(Succeed()) @@ -546,86 +600,32 @@ var _ = Describe("Test placement v1beta1 API validation", func() { Expect(hubClient.Delete(ctx, &crp)).Should(Succeed()) }) - It("should allow update of ClusterResourcePlacement with StatusReportingScope NamespaceAccessible, one namespace plus other cluster-scoped resources", func() { - crp.Spec.ResourceSelectors = append(crp.Spec.ResourceSelectors, []placementv1beta1.ResourceSelectorTerm{ - { - Group: "rbac.authorization.k8s.io", - Version: "v1", - Kind: "ClusterRole", - Name: "test-cluster-role", - }, - { - Group: "", - Version: "v1", - Kind: "PersistentVolume", - Name: "test-pv", - }, - }...) - Expect(hubClient.Update(ctx, &crp)).Should(Succeed()) - }) - - It("should deny update of ClusterResourcePlacement with StatusReportingScope NamespaceAccessible and multiple namespace selectors", func() { - crp.Spec.ResourceSelectors = append(crp.Spec.ResourceSelectors, []placementv1beta1.ResourceSelectorTerm{ - { - Group: "", - Version: "v1", - Kind: "Namespace", - Name: "test-ns-2", - }, - }...) - err := hubClient.Update(ctx, &crp) - var statusErr *k8sErrors.StatusError - Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Update CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) - Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("when statusReportingScope is NamespaceAccessible, exactly one resourceSelector with kind 'Namespace' is required")) - }) - - It("should deny update of ClusterResourcePlacement with StatusReportingScope NamespaceAccessible, no namespace selectors", func() { - crp.Spec.ResourceSelectors = []placementv1beta1.ResourceSelectorTerm{ - { - Group: "rbac.authorization.k8s.io", - Version: "v1", - Kind: "ClusterRole", - Name: "test-cluster-role", - }, - { - Group: "", - Version: "v1", - Kind: "PersistentVolume", - Name: "test-pv", - }, - } - err := hubClient.Update(ctx, &crp) - var statusErr *k8sErrors.StatusError - Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Update CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) - Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("when statusReportingScope is NamespaceAccessible, exactly one resourceSelector with kind 'Namespace' is required")) - }) - - It("should deny update of ClusterResourcePlacement StatusReportingScope to ClusterScopeOnly due to immutability", func() { - crp.Spec.StatusReportingScope = placementv1beta1.ClusterScopeOnly + It("should deny update of ClusterResourcePlacement with nil policy", func() { + crp.Spec.Policy = nil err := hubClient.Update(ctx, &crp) var statusErr *k8sErrors.StatusError Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Update CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) - Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("statusReportingScope is immutable")) + Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("policy cannot be removed once set")) }) - It("should deny update of ClusterResourcePlacement StatusReportingScope to empty string", func() { - crp.Spec.StatusReportingScope = "" + It("should deny update of ClusterResourcePlacement with different placement type", func() { + crp.Spec.Policy.PlacementType = placementv1beta1.PickAllPlacementType err := hubClient.Update(ctx, &crp) var statusErr *k8sErrors.StatusError Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Update CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) - Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("statusReportingScope is immutable")) + Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("placement type is immutable")) }) - It("should deny update of ClusterResourcePlacement StatusReportingScope to unknown scope", func() { - crp.Spec.StatusReportingScope = unknownScope // Invalid scope + It("should deny update of RolloutStrategy type when External", func() { + crp.Spec.Strategy.Type = placementv1beta1.RollingUpdateRolloutStrategyType err := hubClient.Update(ctx, &crp) var statusErr *k8sErrors.StatusError Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Update CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) - Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("supported values: \"ClusterScopeOnly\", \"NamespaceAccessible\"")) + Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("cannot change rollout strategy type from 'External' to other types")) }) }) - Context("Test ClusterResourcePlacement SelectionScope enum validation - allow cases", func() { + Context("Test ClusterResourcePlacement StatusReportingScope validation - create, allow cases", func() { var crp placementv1beta1.ClusterResourcePlacement crpName := fmt.Sprintf(crpNameTemplate, GinkgoParallelProcess()) @@ -633,7 +633,7 @@ var _ = Describe("Test placement v1beta1 API validation", func() { Expect(hubClient.Delete(ctx, &crp)).Should(Succeed()) }) - It("should allow creation of ClusterResourcePlacement with SelectionScope=NamespaceOnly", func() { + It("should allow creation of ClusterResourcePlacement with StatusReportingScope NamespaceAccessible and single namespace selector", func() { crp = placementv1beta1.ClusterResourcePlacement{ ObjectMeta: metav1.ObjectMeta{ Name: crpName, @@ -641,303 +641,1192 @@ var _ = Describe("Test placement v1beta1 API validation", func() { Spec: placementv1beta1.PlacementSpec{ ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ { - Group: "", - Version: "v1", - Kind: "Namespace", - Name: "prod", - SelectionScope: placementv1beta1.NamespaceOnly, + Group: "", + Version: "v1", + Kind: "Namespace", + Name: "test-ns", + }, + }, + StatusReportingScope: placementv1beta1.NamespaceAccessible, + }, + } + Expect(hubClient.Create(ctx, &crp)).Should(Succeed()) + }) + + It("should allow creation of ClusterResourcePlacement with StatusReportingScope NamespaceAccessible and one namespace plus other cluster-scoped resources", func() { + crp = placementv1beta1.ClusterResourcePlacement{ + ObjectMeta: metav1.ObjectMeta{ + Name: crpName, + }, + Spec: placementv1beta1.PlacementSpec{ + ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ + { + Group: "", + Version: "v1", + Kind: "Namespace", + Name: "test-ns", + }, + { + Group: "rbac.authorization.k8s.io", + Version: "v1", + Kind: "ClusterRole", + Name: "test-cluster-role", + }, + { + Group: "", + Version: "v1", + Kind: "PersistentVolume", + Name: "test-pv", + }, + }, + StatusReportingScope: placementv1beta1.NamespaceAccessible, + }, + } + Expect(hubClient.Create(ctx, &crp)).Should(Succeed()) + }) + + It("should allow creation of ClusterResourcePlacement with StatusReportingScope ClusterScopeOnly and multiple namespace selector plus other cluster-scoped resources", func() { + crp = placementv1beta1.ClusterResourcePlacement{ + ObjectMeta: metav1.ObjectMeta{ + Name: crpName, + }, + Spec: placementv1beta1.PlacementSpec{ + ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ + { + Group: "", + Version: "v1", + Kind: "Namespace", + Name: "test-ns-1", + }, + { + Group: "", + Version: "v1", + Kind: "Namespace", + Name: "test-ns-2", + }, + { + Group: "rbac.authorization.k8s.io", + Version: "v1", + Kind: "ClusterRole", + Name: "test-cluster-role", + }, + { + Group: "", + Version: "v1", + Kind: "PersistentVolume", + Name: "test-pv", + }, + }, + StatusReportingScope: placementv1beta1.ClusterScopeOnly, + }, + } + Expect(hubClient.Create(ctx, &crp)).Should(Succeed()) + }) + + It("should allow creation of ClusterResourcePlacement with default StatusReportingScope and multiple namespace selectors plus other cluster-scoped resources", func() { + crp = placementv1beta1.ClusterResourcePlacement{ + ObjectMeta: metav1.ObjectMeta{ + Name: crpName, + }, + Spec: placementv1beta1.PlacementSpec{ + ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ + { + Group: "", + Version: "v1", + Kind: "Namespace", + Name: "test-ns-1", + }, + { + Group: "", + Version: "v1", + Kind: "Namespace", + Name: "test-ns-2", + }, + { + Group: "rbac.authorization.k8s.io", + Version: "v1", + Kind: "ClusterRole", + Name: "test-cluster-role", + }, + { + Group: "", + Version: "v1", + Kind: "PersistentVolume", + Name: "test-pv", + }, + }, + // StatusReportingScope not specified - should default to ClusterScopeOnly + }, + } + Expect(hubClient.Create(ctx, &crp)).Should(Succeed()) + }) + + It("should allow creation of ClusterResourcePlacement with empty string as StatusReportingScope and multiple namespace selectors plus other cluster-scoped resources", func() { + crp = placementv1beta1.ClusterResourcePlacement{ + ObjectMeta: metav1.ObjectMeta{ + Name: crpName, + }, + Spec: placementv1beta1.PlacementSpec{ + ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ + { + Group: "", + Version: "v1", + Kind: "Namespace", + Name: "test-ns-1", + }, + { + Group: "", + Version: "v1", + Kind: "Namespace", + Name: "test-ns-2", + }, + { + Group: "rbac.authorization.k8s.io", + Version: "v1", + Kind: "ClusterRole", + Name: "test-cluster-role", + }, + { + Group: "", + Version: "v1", + Kind: "PersistentVolume", + Name: "test-pv", + }, + }, + StatusReportingScope: "", // defaults to ClusterScopeOnly. + }, + } + Expect(hubClient.Create(ctx, &crp)).Should(Succeed()) + }) + }) + + Context("Test ClusterResourcePlacement StatusReportingScope validation - create, deny cases", func() { + var crp placementv1beta1.ClusterResourcePlacement + crpName := fmt.Sprintf(crpNameTemplate, GinkgoParallelProcess()) + + It("should deny creation of ClusterResourcePlacement with Unknown StatusReportingScope and multiple namespace selectors", func() { + crp = placementv1beta1.ClusterResourcePlacement{ + ObjectMeta: metav1.ObjectMeta{ + Name: crpName, + }, + Spec: placementv1beta1.PlacementSpec{ + ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ + { + Group: "", + Version: "v1", + Kind: "Namespace", + Name: "test-ns-1", + }, + }, + StatusReportingScope: unknownScope, // Invalid scope + }, + } + err := hubClient.Create(ctx, &crp) + var statusErr *k8sErrors.StatusError + Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Create CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) + Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("supported values: \"ClusterScopeOnly\", \"NamespaceAccessible\"")) + }) + + It("should deny creation of ClusterResourcePlacement with StatusReportingScope NamespaceAccessible and multiple namespace selectors", func() { + crp = placementv1beta1.ClusterResourcePlacement{ + ObjectMeta: metav1.ObjectMeta{ + Name: crpName, + }, + Spec: placementv1beta1.PlacementSpec{ + ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ + { + Group: "", + Version: "v1", + Kind: "Namespace", + Name: "test-ns-1", + }, + { + Group: "", + Version: "v1", + Kind: "Namespace", + Name: "test-ns-2", + }, + }, + StatusReportingScope: placementv1beta1.NamespaceAccessible, + }, + } + err := hubClient.Create(ctx, &crp) + var statusErr *k8sErrors.StatusError + Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Create CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) + Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("when statusReportingScope is NamespaceAccessible, exactly one resourceSelector with kind 'Namespace' is required")) + }) + + It("should deny creation of ClusterResourcePlacement with StatusReportingScope NamespaceAccessible and no namespace selector", func() { + crp = placementv1beta1.ClusterResourcePlacement{ + ObjectMeta: metav1.ObjectMeta{ + Name: crpName, + }, + Spec: placementv1beta1.PlacementSpec{ + ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ + { + Group: "rbac.authorization.k8s.io", + Version: "v1", + Kind: "ClusterRole", + Name: "test-cluster-role", + }, + { + Group: "", + Version: "v1", + Kind: "PersistentVolume", + Name: "test-pv", + }, + }, + StatusReportingScope: placementv1beta1.NamespaceAccessible, + }, + } + err := hubClient.Create(ctx, &crp) + var statusErr *k8sErrors.StatusError + Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Create CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) + Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("when statusReportingScope is NamespaceAccessible, exactly one resourceSelector with kind 'Namespace' is required")) + }) + }) + + Context("Test ClusterResourcePlacement ClusterScopeOnly StatusReportingScope validation - update cases", func() { + var crp placementv1beta1.ClusterResourcePlacement + crpName := fmt.Sprintf(crpNameTemplate, GinkgoParallelProcess()) + + BeforeEach(func() { + crp = placementv1beta1.ClusterResourcePlacement{ + ObjectMeta: metav1.ObjectMeta{ + Name: crpName, + }, + Spec: placementv1beta1.PlacementSpec{ + ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ + { + Group: "", + Version: "v1", + Kind: "Namespace", + Name: "test-ns-1", + }, + }, + // By default, StatusReportingScope is ClusterScopeOnly + }, + } + Expect(hubClient.Create(ctx, &crp)).Should(Succeed()) + }) + + AfterEach(func() { + Expect(hubClient.Delete(ctx, &crp)).Should(Succeed()) + }) + + It("should allow empty string for StatusReportingScope in a ClusterResourcePlacement when StatusReportingScope is not set", func() { + Expect(crp.Spec.StatusReportingScope).To(Equal(placementv1beta1.ClusterScopeOnly), "CRP should have default StatusReportingScope ClusterScopeOnly") + crp.Spec.StatusReportingScope = "" // Empty string should default to ClusterScopeOnly + Expect(hubClient.Update(ctx, &crp)).Should(Succeed()) + Expect(crp.Spec.StatusReportingScope).To(Equal(placementv1beta1.ClusterScopeOnly), "CRP should have default StatusReportingScope ClusterScopeOnly") + }) + + It("should allow update of ClusterResourcePlacement which has default StatusReportingScope, multiple namespace resource selectors", func() { + crp.Spec.ResourceSelectors = append(crp.Spec.ResourceSelectors, []placementv1beta1.ResourceSelectorTerm{ + { + Group: "", + Version: "v1", + Kind: "Namespace", + Name: "test-ns-2", + }, + }...) + Expect(hubClient.Update(ctx, &crp)).Should(Succeed()) + }) + + It("should allow update of ClusterResourcePlacement with StatusReportingScope ClusterScopeOnly, multiple namespace resource selectors", func() { + crp.Spec.ResourceSelectors = append(crp.Spec.ResourceSelectors, []placementv1beta1.ResourceSelectorTerm{ + { + Group: "", + Version: "v1", + Kind: "Namespace", + Name: "test-ns-2", + }, + }...) + crp.Spec.StatusReportingScope = placementv1beta1.ClusterScopeOnly + Expect(hubClient.Update(ctx, &crp)).Should(Succeed()) + }) + + It("should deny update of ClusterResourcePlacement StatusReportingScope to NamespaceAccessible due to immutability", func() { + crp.Spec.StatusReportingScope = placementv1beta1.NamespaceAccessible + err := hubClient.Update(ctx, &crp) + var statusErr *k8sErrors.StatusError + Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Update CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) + Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("statusReportingScope is immutable")) + }) + + It("should deny update of ClusterResourcePlacement StatusReportingScope to unknown scope", func() { + crp.Spec.StatusReportingScope = unknownScope // Invalid scope + err := hubClient.Update(ctx, &crp) + var statusErr *k8sErrors.StatusError + Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Update CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) + Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("supported values: \"ClusterScopeOnly\", \"NamespaceAccessible\"")) + }) + }) + + Context("Test ClusterResourcePlacement NamespaceAccessible StatusReportingScope validation - update cases", func() { + var crp placementv1beta1.ClusterResourcePlacement + crpName := fmt.Sprintf(crpNameTemplate, GinkgoParallelProcess()) + + BeforeEach(func() { + crp = placementv1beta1.ClusterResourcePlacement{ + ObjectMeta: metav1.ObjectMeta{ + Name: crpName, + }, + Spec: placementv1beta1.PlacementSpec{ + ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ + { + Group: "", + Version: "v1", + Kind: "Namespace", + Name: "test-ns-1", + }, + }, + StatusReportingScope: placementv1beta1.NamespaceAccessible, + }, + } + Expect(hubClient.Create(ctx, &crp)).Should(Succeed()) + }) + + AfterEach(func() { + Expect(hubClient.Delete(ctx, &crp)).Should(Succeed()) + }) + + It("should allow update of ClusterResourcePlacement with StatusReportingScope NamespaceAccessible, one namespace plus other cluster-scoped resources", func() { + crp.Spec.ResourceSelectors = append(crp.Spec.ResourceSelectors, []placementv1beta1.ResourceSelectorTerm{ + { + Group: "rbac.authorization.k8s.io", + Version: "v1", + Kind: "ClusterRole", + Name: "test-cluster-role", + }, + { + Group: "", + Version: "v1", + Kind: "PersistentVolume", + Name: "test-pv", + }, + }...) + Expect(hubClient.Update(ctx, &crp)).Should(Succeed()) + }) + + It("should deny update of ClusterResourcePlacement with StatusReportingScope NamespaceAccessible and multiple namespace selectors", func() { + crp.Spec.ResourceSelectors = append(crp.Spec.ResourceSelectors, []placementv1beta1.ResourceSelectorTerm{ + { + Group: "", + Version: "v1", + Kind: "Namespace", + Name: "test-ns-2", + }, + }...) + err := hubClient.Update(ctx, &crp) + var statusErr *k8sErrors.StatusError + Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Update CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) + Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("when statusReportingScope is NamespaceAccessible, exactly one resourceSelector with kind 'Namespace' is required")) + }) + + It("should deny update of ClusterResourcePlacement with StatusReportingScope NamespaceAccessible, no namespace selectors", func() { + crp.Spec.ResourceSelectors = []placementv1beta1.ResourceSelectorTerm{ + { + Group: "rbac.authorization.k8s.io", + Version: "v1", + Kind: "ClusterRole", + Name: "test-cluster-role", + }, + { + Group: "", + Version: "v1", + Kind: "PersistentVolume", + Name: "test-pv", + }, + } + err := hubClient.Update(ctx, &crp) + var statusErr *k8sErrors.StatusError + Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Update CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) + Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("when statusReportingScope is NamespaceAccessible, exactly one resourceSelector with kind 'Namespace' is required")) + }) + + It("should deny update of ClusterResourcePlacement StatusReportingScope to ClusterScopeOnly due to immutability", func() { + crp.Spec.StatusReportingScope = placementv1beta1.ClusterScopeOnly + err := hubClient.Update(ctx, &crp) + var statusErr *k8sErrors.StatusError + Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Update CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) + Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("statusReportingScope is immutable")) + }) + + It("should deny update of ClusterResourcePlacement StatusReportingScope to empty string", func() { + crp.Spec.StatusReportingScope = "" + err := hubClient.Update(ctx, &crp) + var statusErr *k8sErrors.StatusError + Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Update CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) + Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("statusReportingScope is immutable")) + }) + + It("should deny update of ClusterResourcePlacement StatusReportingScope to unknown scope", func() { + crp.Spec.StatusReportingScope = unknownScope // Invalid scope + err := hubClient.Update(ctx, &crp) + var statusErr *k8sErrors.StatusError + Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Update CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) + Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("supported values: \"ClusterScopeOnly\", \"NamespaceAccessible\"")) + }) + }) + + Context("Test ClusterResourcePlacement SelectionScope enum validation - allow cases", func() { + var crp placementv1beta1.ClusterResourcePlacement + crpName := fmt.Sprintf(crpNameTemplate, GinkgoParallelProcess()) + + AfterEach(func() { + Expect(hubClient.Delete(ctx, &crp)).Should(Succeed()) + }) + + It("should allow creation of ClusterResourcePlacement with SelectionScope=NamespaceOnly", func() { + crp = placementv1beta1.ClusterResourcePlacement{ + ObjectMeta: metav1.ObjectMeta{ + Name: crpName, + }, + Spec: placementv1beta1.PlacementSpec{ + ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ + { + Group: "", + Version: "v1", + Kind: "Namespace", + Name: "prod", + SelectionScope: placementv1beta1.NamespaceOnly, + }, + }, + }, + } + Expect(hubClient.Create(ctx, &crp)).Should(Succeed()) + Expect(crp.Spec.ResourceSelectors[0].SelectionScope).Should(Equal(placementv1beta1.NamespaceOnly)) + }) + + It("should allow creation of ClusterResourcePlacement with SelectionScope=NamespaceWithResources", func() { + crp = placementv1beta1.ClusterResourcePlacement{ + ObjectMeta: metav1.ObjectMeta{ + Name: crpName, + }, + Spec: placementv1beta1.PlacementSpec{ + ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ + { + Group: "", + Version: "v1", + Kind: "Namespace", + Name: "prod", + SelectionScope: placementv1beta1.NamespaceWithResources, + }, + }, + }, + } + Expect(hubClient.Create(ctx, &crp)).Should(Succeed()) + Expect(crp.Spec.ResourceSelectors[0].SelectionScope).Should(Equal(placementv1beta1.NamespaceWithResources)) + }) + + It("should allow creation of ClusterResourcePlacement with SelectionScope=NamespaceWithResourceSelectors", func() { + crp = placementv1beta1.ClusterResourcePlacement{ + ObjectMeta: metav1.ObjectMeta{ + Name: crpName, + }, + Spec: placementv1beta1.PlacementSpec{ + ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ + { + Group: "", + Version: "v1", + Kind: "Namespace", + Name: "prod", + SelectionScope: placementv1beta1.NamespaceWithResourceSelectors, + }, + }, + }, + } + Expect(hubClient.Create(ctx, &crp)).Should(Succeed()) + Expect(crp.Spec.ResourceSelectors[0].SelectionScope).Should(Equal(placementv1beta1.NamespaceWithResourceSelectors)) + }) + + It("should allow creation of ClusterResourcePlacement with SelectionScope not specified (defaults to NamespaceWithResources)", func() { + crp = placementv1beta1.ClusterResourcePlacement{ + ObjectMeta: metav1.ObjectMeta{ + Name: crpName, + }, + Spec: placementv1beta1.PlacementSpec{ + ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ + { + Group: "", + Version: "v1", + Kind: "Namespace", + Name: "prod", + // SelectionScope not specified - should default to NamespaceWithResources + }, + }, + }, + } + Expect(hubClient.Create(ctx, &crp)).Should(Succeed()) + Expect(crp.Spec.ResourceSelectors[0].SelectionScope).Should(Equal(placementv1beta1.NamespaceWithResources)) + }) + + It("should allow creation of ClusterResourcePlacement with empty SelectionScope (defaults to NamespaceWithResources)", func() { + crp = placementv1beta1.ClusterResourcePlacement{ + ObjectMeta: metav1.ObjectMeta{ + Name: crpName, + }, + Spec: placementv1beta1.PlacementSpec{ + ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ + { + Group: "", + Version: "v1", + Kind: "Namespace", + Name: "prod", + SelectionScope: "", + }, + }, + }, + } + Expect(hubClient.Create(ctx, &crp)).Should(Succeed()) + Expect(crp.Spec.ResourceSelectors[0].SelectionScope).Should(Equal(placementv1beta1.NamespaceWithResources)) + }) + + It("should allow creation of CRP with NamespaceWithResourceSelectors selecting by name and additional resource selectors", func() { + crp = placementv1beta1.ClusterResourcePlacement{ + ObjectMeta: metav1.ObjectMeta{ + Name: crpName, + }, + Spec: placementv1beta1.PlacementSpec{ + ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ + { + Group: "", + Version: "v1", + Kind: "Namespace", + Name: "prod", + SelectionScope: placementv1beta1.NamespaceWithResourceSelectors, + }, + { + Group: "apps", + Version: "v1", + Kind: "Deployment", + Name: "frontend", + }, + { + Group: "rbac.authorization.k8s.io", + Version: "v1", + Kind: "ClusterRole", + Name: "test-cluster-role", + }, + }, + }, + } + Expect(hubClient.Create(ctx, &crp)).Should(Succeed()) + }) + }) + + Context("Test ClusterResourcePlacement SelectionScope enum validation - deny cases", func() { + var crp placementv1beta1.ClusterResourcePlacement + crpName := fmt.Sprintf(crpNameTemplate, GinkgoParallelProcess()) + + It("should deny creation of ClusterResourcePlacement with invalid SelectionScope value", func() { + crp = placementv1beta1.ClusterResourcePlacement{ + ObjectMeta: metav1.ObjectMeta{ + Name: crpName, + }, + Spec: placementv1beta1.PlacementSpec{ + ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ + { + Group: "", + Version: "v1", + Kind: "Namespace", + Name: "prod", + SelectionScope: "InvalidScope", + }, + }, + }, + } + err := hubClient.Create(ctx, &crp) + var statusErr *k8sErrors.StatusError + Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Create CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) + Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("supported values: \"NamespaceOnly\", \"NamespaceWithResources\", \"NamespaceWithResourceSelectors\"")) + }) + + It("should deny update of ClusterResourcePlacement SelectionScope to invalid value", func() { + crp = placementv1beta1.ClusterResourcePlacement{ + ObjectMeta: metav1.ObjectMeta{ + Name: crpName, + }, + Spec: placementv1beta1.PlacementSpec{ + ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ + { + Group: "", + Version: "v1", + Kind: "Namespace", + Name: "prod", + SelectionScope: placementv1beta1.NamespaceWithResources, + }, + }, + }, + } + Expect(hubClient.Create(ctx, &crp)).Should(Succeed()) + + // Try to update to invalid SelectionScope + crp.Spec.ResourceSelectors[0].SelectionScope = "InvalidScope" + err := hubClient.Update(ctx, &crp) + var statusErr *k8sErrors.StatusError + Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Update CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) + Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("supported values: \"NamespaceOnly\", \"NamespaceWithResources\", \"NamespaceWithResourceSelectors\"")) + + // Cleanup + Expect(hubClient.Delete(ctx, &crp)).Should(Succeed()) + }) + + It("should deny creation of CRP with multiple namespace selectors with NamespaceWithResourceSelectors mode", func() { + crp = placementv1beta1.ClusterResourcePlacement{ + ObjectMeta: metav1.ObjectMeta{ + Name: crpName, + }, + Spec: placementv1beta1.PlacementSpec{ + ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ + { + Group: "", + Version: "v1", + Kind: "Namespace", + Name: "prod-1", + SelectionScope: placementv1beta1.NamespaceWithResourceSelectors, + }, + { + Group: "", + Version: "v1", + Kind: "Namespace", + Name: "prod-2", + SelectionScope: placementv1beta1.NamespaceWithResourceSelectors, + }, + }, + }, + } + err := hubClient.Create(ctx, &crp) + var statusErr *k8sErrors.StatusError + Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Create CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) + Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("only one namespace selector with NamespaceWithResourceSelectors mode is allowed")) + }) + + It("should deny creation of CRP with NamespaceWithResourceSelectors using label selector", func() { + crp = placementv1beta1.ClusterResourcePlacement{ + ObjectMeta: metav1.ObjectMeta{ + Name: crpName, + }, + Spec: placementv1beta1.PlacementSpec{ + ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ + { + Group: "", + Version: "v1", + Kind: "Namespace", + LabelSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"env": "prod"}, + }, + SelectionScope: placementv1beta1.NamespaceWithResourceSelectors, + }, + }, + }, + } + err := hubClient.Create(ctx, &crp) + var statusErr *k8sErrors.StatusError + Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Create CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) + Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("namespace selector with NamespaceWithResourceSelectors mode must select by name")) + }) + + It("should deny creation of CRP with NamespaceWithResourceSelectors using empty name", func() { + crp = placementv1beta1.ClusterResourcePlacement{ + ObjectMeta: metav1.ObjectMeta{ + Name: crpName, + }, + Spec: placementv1beta1.PlacementSpec{ + ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ + { + Group: "", + Version: "v1", + Kind: "Namespace", + Name: "", // Empty name + SelectionScope: placementv1beta1.NamespaceWithResourceSelectors, + }, + }, + }, + } + err := hubClient.Create(ctx, &crp) + var statusErr *k8sErrors.StatusError + Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Create CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) + Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("namespace selector with NamespaceWithResourceSelectors mode must select by name")) + }) + + It("should deny creation of CRP mixing NamespaceWithResourceSelectors with other namespace selectors", func() { + crp = placementv1beta1.ClusterResourcePlacement{ + ObjectMeta: metav1.ObjectMeta{ + Name: crpName, + }, + Spec: placementv1beta1.PlacementSpec{ + ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ + { + Group: "", + Version: "v1", + Kind: "Namespace", + Name: "prod", + SelectionScope: placementv1beta1.NamespaceWithResourceSelectors, + }, + { + Group: "", + Version: "v1", + Kind: "Namespace", + Name: "staging", + // Default SelectionScope is NamespaceWithResources + }, + }, + }, + } + err := hubClient.Create(ctx, &crp) + var statusErr *k8sErrors.StatusError + Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Create CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) + Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("when using NamespaceWithResourceSelectors mode, only one namespace selector is allowed")) + }) + }) + + Context("Test ResourcePlacement API validation", func() { + rpNamespace := "default" + + AfterEach(func() { + // Clean up the RP created during each test case. + rpName := fmt.Sprintf(rpNameTemplate, GinkgoParallelProcess()) + + Eventually(func() error { + rp := &placementv1beta1.ResourcePlacement{ + ObjectMeta: metav1.ObjectMeta{ + Name: rpName, + Namespace: rpNamespace, + }, + } + if err := hubClient.Delete(ctx, rp); err != nil && !k8sErrors.IsNotFound(err) { + return fmt.Errorf("failed to delete RP: %w", err) + } + + if err := hubClient.Get(ctx, client.ObjectKey{Name: rpName, Namespace: rpNamespace}, &placementv1beta1.ResourcePlacement{}); !k8sErrors.IsNotFound(err) { + return fmt.Errorf("RP still exists after deletion attempt (error: %w)", err) + } + return nil + }, eventuallyDuration, eventuallyInterval).Should(Succeed()) + }) + + It("cannot set name and label selector in a resource selector at the same time", func() { + rpName := fmt.Sprintf(rpNameTemplate, GinkgoParallelProcess()) + + rp := &placementv1beta1.ResourcePlacement{ + ObjectMeta: metav1.ObjectMeta{ + Name: rpName, + Namespace: rpNamespace, + }, + Spec: placementv1beta1.PlacementSpec{ + ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ + { + Group: "", + Version: "v1", + Kind: "ConfigMap", + Name: nonExistentCMName, + LabelSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"foo": "bar"}, + }, }, }, }, } - Expect(hubClient.Create(ctx, &crp)).Should(Succeed()) - Expect(crp.Spec.ResourceSelectors[0].SelectionScope).Should(Equal(placementv1beta1.NamespaceOnly)) + + err := hubClient.Create(ctx, rp) + Expect(err).To(HaveOccurred(), "Expected error when creating RP with both name and label selector in a resource selector") + var statusErr *k8sErrors.StatusError + Expect(errors.As(err, &statusErr)).To(BeTrue(), "The returned error is not a StatusError") + Expect(statusErr.Status().Message).Should(ContainSubstring("name and labelSelector are mutually exclusive in a resource selector")) }) - It("should allow creation of ClusterResourcePlacement with SelectionScope=NamespaceWithResources", func() { - crp = placementv1beta1.ClusterResourcePlacement{ + It("cannot set numberOfClusters when placementType is PickFixed", func() { + rpName := fmt.Sprintf(rpNameTemplate, GinkgoParallelProcess()) + + var numberOfClusters int32 = 1 + rp := &placementv1beta1.ResourcePlacement{ ObjectMeta: metav1.ObjectMeta{ - Name: crpName, + Name: rpName, + Namespace: rpNamespace, }, Spec: placementv1beta1.PlacementSpec{ ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ { - Group: "", - Version: "v1", - Kind: "Namespace", - Name: "prod", - SelectionScope: placementv1beta1.NamespaceWithResources, + Group: "", + Version: "v1", + Kind: "ConfigMap", + Name: nonExistentCMName, }, }, + Policy: &placementv1beta1.PlacementPolicy{ + PlacementType: placementv1beta1.PickFixedPlacementType, + ClusterNames: []string{clusterName1}, + NumberOfClusters: &numberOfClusters, + }, }, } - Expect(hubClient.Create(ctx, &crp)).Should(Succeed()) - Expect(crp.Spec.ResourceSelectors[0].SelectionScope).Should(Equal(placementv1beta1.NamespaceWithResources)) + + err := hubClient.Create(ctx, rp) + Expect(err).To(HaveOccurred(), "Expected error when creating RP with numberOfClusters set for PickFixed placementType") + var statusErr *k8sErrors.StatusError + Expect(errors.As(err, &statusErr)).To(BeTrue(), "The returned error is not a StatusError") + Expect(statusErr.Status().Message).Should(ContainSubstring("affinity, numberOfClusters, topologySpreadConstraints, and tolerations cannot be set when placementType is PickFixed")) }) - It("should allow creation of ClusterResourcePlacement with SelectionScope=NamespaceWithResourceSelectors", func() { - crp = placementv1beta1.ClusterResourcePlacement{ + It("cannot set affinity when placementType is PickFixed", func() { + rpName := fmt.Sprintf(rpNameTemplate, GinkgoParallelProcess()) + + rp := &placementv1beta1.ResourcePlacement{ ObjectMeta: metav1.ObjectMeta{ - Name: crpName, + Name: rpName, + Namespace: rpNamespace, }, Spec: placementv1beta1.PlacementSpec{ ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ { - Group: "", - Version: "v1", - Kind: "Namespace", - Name: "prod", - SelectionScope: placementv1beta1.NamespaceWithResourceSelectors, + Group: "", + Version: "v1", + Kind: "ConfigMap", + Name: nonExistentCMName, + }, + }, + Policy: &placementv1beta1.PlacementPolicy{ + PlacementType: placementv1beta1.PickFixedPlacementType, + ClusterNames: []string{clusterName1}, + Affinity: &placementv1beta1.Affinity{ + ClusterAffinity: &placementv1beta1.ClusterAffinity{ + RequiredDuringSchedulingIgnoredDuringExecution: &placementv1beta1.ClusterSelector{ + ClusterSelectorTerms: []placementv1beta1.ClusterSelectorTerm{ + { + LabelSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"foo": "bar"}, + }, + }, + }, + }, + }, }, }, }, } - Expect(hubClient.Create(ctx, &crp)).Should(Succeed()) - Expect(crp.Spec.ResourceSelectors[0].SelectionScope).Should(Equal(placementv1beta1.NamespaceWithResourceSelectors)) + + err := hubClient.Create(ctx, rp) + Expect(err).To(HaveOccurred(), "Expected error when creating RP with affinity set for PickFixed placementType") + var statusErr *k8sErrors.StatusError + Expect(errors.As(err, &statusErr)).To(BeTrue(), "The returned error is not a StatusError") + Expect(statusErr.Status().Message).Should(ContainSubstring("affinity, numberOfClusters, topologySpreadConstraints, and tolerations cannot be set when placementType is PickFixed")) }) - It("should allow creation of ClusterResourcePlacement with SelectionScope not specified (defaults to NamespaceWithResources)", func() { - crp = placementv1beta1.ClusterResourcePlacement{ + It("cannot set topologySpreadConstraints when placementType is PickFixed", func() { + rpName := fmt.Sprintf(rpNameTemplate, GinkgoParallelProcess()) + + rp := &placementv1beta1.ResourcePlacement{ ObjectMeta: metav1.ObjectMeta{ - Name: crpName, + Name: rpName, + Namespace: rpNamespace, }, Spec: placementv1beta1.PlacementSpec{ ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ { Group: "", Version: "v1", - Kind: "Namespace", - Name: "prod", - // SelectionScope not specified - should default to NamespaceWithResources + Kind: "ConfigMap", + Name: nonExistentCMName, + }, + }, + Policy: &placementv1beta1.PlacementPolicy{ + PlacementType: placementv1beta1.PickFixedPlacementType, + ClusterNames: []string{clusterName1}, + TopologySpreadConstraints: []placementv1beta1.TopologySpreadConstraint{ + { + TopologyKey: "topology.kubernetes.io/zone", + }, }, }, }, } - Expect(hubClient.Create(ctx, &crp)).Should(Succeed()) - Expect(crp.Spec.ResourceSelectors[0].SelectionScope).Should(Equal(placementv1beta1.NamespaceWithResources)) + + err := hubClient.Create(ctx, rp) + Expect(err).To(HaveOccurred(), "Expected error when creating RP with topologySpreadConstraints set for PickFixed placementType") + var statusErr *k8sErrors.StatusError + Expect(errors.As(err, &statusErr)).To(BeTrue(), "The returned error is not a StatusError") + Expect(statusErr.Status().Message).Should(ContainSubstring("affinity, numberOfClusters, topologySpreadConstraints, and tolerations cannot be set when placementType is PickFixed")) }) - It("should allow creation of ClusterResourcePlacement with empty SelectionScope (defaults to NamespaceWithResources)", func() { - crp = placementv1beta1.ClusterResourcePlacement{ + It("cannot set tolerations when placementType is PickFixed", func() { + rpName := fmt.Sprintf(rpNameTemplate, GinkgoParallelProcess()) + + rp := &placementv1beta1.ResourcePlacement{ ObjectMeta: metav1.ObjectMeta{ - Name: crpName, + Name: rpName, + Namespace: rpNamespace, }, Spec: placementv1beta1.PlacementSpec{ ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ { - Group: "", - Version: "v1", - Kind: "Namespace", - Name: "prod", - SelectionScope: "", + Group: "", + Version: "v1", + Kind: "ConfigMap", + Name: nonExistentCMName, + }, + }, + Policy: &placementv1beta1.PlacementPolicy{ + PlacementType: placementv1beta1.PickFixedPlacementType, + ClusterNames: []string{clusterName1}, + Tolerations: []placementv1beta1.Toleration{ + { + Key: "foo", + Operator: corev1.TolerationOpEqual, + Value: "bar", + Effect: corev1.TaintEffectNoSchedule, + }, }, }, }, } - Expect(hubClient.Create(ctx, &crp)).Should(Succeed()) - Expect(crp.Spec.ResourceSelectors[0].SelectionScope).Should(Equal(placementv1beta1.NamespaceWithResources)) + + err := hubClient.Create(ctx, rp) + Expect(err).To(HaveOccurred(), "Expected error when creating RP with tolerations set for PickFixed placementType") + var statusErr *k8sErrors.StatusError + Expect(errors.As(err, &statusErr)).To(BeTrue(), "The returned error is not a StatusError") + Expect(statusErr.Status().Message).Should(ContainSubstring("affinity, numberOfClusters, topologySpreadConstraints, and tolerations cannot be set when placementType is PickFixed")) }) - It("should allow creation of CRP with NamespaceWithResourceSelectors selecting by name and additional resource selectors", func() { - crp = placementv1beta1.ClusterResourcePlacement{ + It("cannot set clusterNames when placementType is PickAll", func() { + rpName := fmt.Sprintf(rpNameTemplate, GinkgoParallelProcess()) + + rp := &placementv1beta1.ResourcePlacement{ ObjectMeta: metav1.ObjectMeta{ - Name: crpName, + Name: rpName, + Namespace: rpNamespace, }, Spec: placementv1beta1.PlacementSpec{ ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ { - Group: "", - Version: "v1", - Kind: "Namespace", - Name: "prod", - SelectionScope: placementv1beta1.NamespaceWithResourceSelectors, - }, - { - Group: "apps", - Version: "v1", - Kind: "Deployment", - Name: "frontend", - }, - { - Group: "rbac.authorization.k8s.io", + Group: "", Version: "v1", - Kind: "ClusterRole", - Name: "test-cluster-role", + Kind: "ConfigMap", + Name: nonExistentCMName, }, }, + Policy: &placementv1beta1.PlacementPolicy{ + PlacementType: placementv1beta1.PickAllPlacementType, + ClusterNames: []string{clusterName1}, + }, }, } - Expect(hubClient.Create(ctx, &crp)).Should(Succeed()) + + err := hubClient.Create(ctx, rp) + Expect(err).To(HaveOccurred(), "Expected error when creating RP with clusterNames set for PickAll placementType") + var statusErr *k8sErrors.StatusError + Expect(errors.As(err, &statusErr)).To(BeTrue(), "The returned error is not a StatusError") + Expect(statusErr.Status().Message).Should(ContainSubstring("clusterNames, numberOfClusters, and topologySpreadConstraints cannot be set when placementType is PickAll")) }) - }) - Context("Test ClusterResourcePlacement SelectionScope enum validation - deny cases", func() { - var crp placementv1beta1.ClusterResourcePlacement - crpName := fmt.Sprintf(crpNameTemplate, GinkgoParallelProcess()) + It("cannot set numberOfClusters when placementType is PickAll", func() { + rpName := fmt.Sprintf(rpNameTemplate, GinkgoParallelProcess()) - It("should deny creation of ClusterResourcePlacement with invalid SelectionScope value", func() { - crp = placementv1beta1.ClusterResourcePlacement{ + var numberOfClusters int32 = 1 + rp := &placementv1beta1.ResourcePlacement{ ObjectMeta: metav1.ObjectMeta{ - Name: crpName, + Name: rpName, + Namespace: rpNamespace, }, Spec: placementv1beta1.PlacementSpec{ ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ { - Group: "", - Version: "v1", - Kind: "Namespace", - Name: "prod", - SelectionScope: "InvalidScope", + Group: "", + Version: "v1", + Kind: "ConfigMap", + Name: nonExistentCMName, }, }, + Policy: &placementv1beta1.PlacementPolicy{ + PlacementType: placementv1beta1.PickAllPlacementType, + NumberOfClusters: &numberOfClusters, + }, }, } - err := hubClient.Create(ctx, &crp) + + err := hubClient.Create(ctx, rp) + Expect(err).To(HaveOccurred(), "Expected error when creating RP with numberOfClusters set for PickAll placementType") var statusErr *k8sErrors.StatusError - Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Create CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) - Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("supported values: \"NamespaceOnly\", \"NamespaceWithResources\", \"NamespaceWithResourceSelectors\"")) + Expect(errors.As(err, &statusErr)).To(BeTrue(), "The returned error is not a StatusError") + Expect(statusErr.Status().Message).Should(ContainSubstring("clusterNames, numberOfClusters, and topologySpreadConstraints cannot be set when placementType is PickAll")) }) - It("should deny update of ClusterResourcePlacement SelectionScope to invalid value", func() { - crp = placementv1beta1.ClusterResourcePlacement{ + It("cannot set topologySpreadConstraints when placementType is PickAll", func() { + rpName := fmt.Sprintf(rpNameTemplate, GinkgoParallelProcess()) + + rp := &placementv1beta1.ResourcePlacement{ ObjectMeta: metav1.ObjectMeta{ - Name: crpName, + Name: rpName, + Namespace: rpNamespace, }, Spec: placementv1beta1.PlacementSpec{ ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ { - Group: "", - Version: "v1", - Kind: "Namespace", - Name: "prod", - SelectionScope: placementv1beta1.NamespaceWithResources, + Group: "", + Version: "v1", + Kind: "ConfigMap", + Name: nonExistentCMName, + }, + }, + Policy: &placementv1beta1.PlacementPolicy{ + PlacementType: placementv1beta1.PickAllPlacementType, + TopologySpreadConstraints: []placementv1beta1.TopologySpreadConstraint{ + { + TopologyKey: "topology.kubernetes.io/zone", + }, }, }, }, } - Expect(hubClient.Create(ctx, &crp)).Should(Succeed()) - // Try to update to invalid SelectionScope - crp.Spec.ResourceSelectors[0].SelectionScope = "InvalidScope" - err := hubClient.Update(ctx, &crp) + err := hubClient.Create(ctx, rp) + Expect(err).To(HaveOccurred(), "Expected error when creating RP with topologySpreadConstraints set for PickAll placementType") var statusErr *k8sErrors.StatusError - Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Update CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) - Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("supported values: \"NamespaceOnly\", \"NamespaceWithResources\", \"NamespaceWithResourceSelectors\"")) - - // Cleanup - Expect(hubClient.Delete(ctx, &crp)).Should(Succeed()) + Expect(errors.As(err, &statusErr)).To(BeTrue(), "The returned error is not a StatusError") + Expect(statusErr.Status().Message).Should(ContainSubstring("clusterNames, numberOfClusters, and topologySpreadConstraints cannot be set when placementType is PickAll")) }) - It("should deny creation of CRP with multiple namespace selectors with NamespaceWithResourceSelectors mode", func() { - crp = placementv1beta1.ClusterResourcePlacement{ + It("cannot set clusterNames when placementType is PickN", func() { + rpName := fmt.Sprintf(rpNameTemplate, GinkgoParallelProcess()) + + var numberOfClusters int32 = 1 + rp := &placementv1beta1.ResourcePlacement{ ObjectMeta: metav1.ObjectMeta{ - Name: crpName, + Name: rpName, + Namespace: rpNamespace, }, Spec: placementv1beta1.PlacementSpec{ ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ { - Group: "", - Version: "v1", - Kind: "Namespace", - Name: "prod-1", - SelectionScope: placementv1beta1.NamespaceWithResourceSelectors, - }, - { - Group: "", - Version: "v1", - Kind: "Namespace", - Name: "prod-2", - SelectionScope: placementv1beta1.NamespaceWithResourceSelectors, + Group: "", + Version: "v1", + Kind: "ConfigMap", + Name: nonExistentCMName, }, }, + Policy: &placementv1beta1.PlacementPolicy{ + PlacementType: placementv1beta1.PickNPlacementType, + ClusterNames: []string{clusterName1}, + NumberOfClusters: &numberOfClusters, + }, }, } - err := hubClient.Create(ctx, &crp) + + err := hubClient.Create(ctx, rp) + Expect(err).To(HaveOccurred(), "Expected error when creating RP with clusterNames set for PickN placementType") var statusErr *k8sErrors.StatusError - Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Create CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) - Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("only one namespace selector with NamespaceWithResourceSelectors mode is allowed")) + Expect(errors.As(err, &statusErr)).To(BeTrue(), "The returned error is not a StatusError") + Expect(statusErr.Status().Message).Should(ContainSubstring("clusterNames cannot be set and numberOfClusters must be set when placementType is PickN")) }) - It("should deny creation of CRP with NamespaceWithResourceSelectors using label selector", func() { - crp = placementv1beta1.ClusterResourcePlacement{ + It("must set numberOfClusters when placementType is PickN", func() { + rpName := fmt.Sprintf(rpNameTemplate, GinkgoParallelProcess()) + + rp := &placementv1beta1.ResourcePlacement{ ObjectMeta: metav1.ObjectMeta{ - Name: crpName, + Name: rpName, + Namespace: rpNamespace, }, Spec: placementv1beta1.PlacementSpec{ ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ { Group: "", Version: "v1", - Kind: "Namespace", - LabelSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{"env": "prod"}, - }, - SelectionScope: placementv1beta1.NamespaceWithResourceSelectors, + Kind: "ConfigMap", + Name: nonExistentCMName, }, }, + Policy: &placementv1beta1.PlacementPolicy{ + PlacementType: placementv1beta1.PickNPlacementType, + }, }, } - err := hubClient.Create(ctx, &crp) + + err := hubClient.Create(ctx, rp) + Expect(err).To(HaveOccurred(), "Expected error when creating RP without numberOfClusters set for PickN placementType") var statusErr *k8sErrors.StatusError - Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Create CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) - Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("namespace selector with NamespaceWithResourceSelectors mode must select by name")) + Expect(errors.As(err, &statusErr)).To(BeTrue(), "The returned error is not a StatusError") + Expect(statusErr.Status().Message).Should(ContainSubstring("clusterNames cannot be set and numberOfClusters must be set when placementType is PickN")) }) - It("should deny creation of CRP with NamespaceWithResourceSelectors using empty name", func() { - crp = placementv1beta1.ClusterResourcePlacement{ + It("cannot set a toleration value when its operator is Exists", func() { + rpName := fmt.Sprintf(rpNameTemplate, GinkgoParallelProcess()) + + rp := &placementv1beta1.ResourcePlacement{ ObjectMeta: metav1.ObjectMeta{ - Name: crpName, + Name: rpName, + Namespace: rpNamespace, }, Spec: placementv1beta1.PlacementSpec{ ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ { - Group: "", - Version: "v1", - Kind: "Namespace", - Name: "", // Empty name - SelectionScope: placementv1beta1.NamespaceWithResourceSelectors, + Group: "", + Version: "v1", + Kind: "ConfigMap", + Name: nonExistentCMName, + }, + }, + Policy: &placementv1beta1.PlacementPolicy{ + PlacementType: placementv1beta1.PickAllPlacementType, + Tolerations: []placementv1beta1.Toleration{ + { + Key: "foo", + Operator: corev1.TolerationOpExists, + Value: "bar", + Effect: corev1.TaintEffectNoSchedule, + }, }, }, }, } - err := hubClient.Create(ctx, &crp) + + err := hubClient.Create(ctx, rp) + Expect(err).To(HaveOccurred(), "Expected error when creating RP with a toleration value set for operator Exists") var statusErr *k8sErrors.StatusError - Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Create CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) - Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("namespace selector with NamespaceWithResourceSelectors mode must select by name")) + Expect(errors.As(err, &statusErr)).To(BeTrue(), "The returned error is not a StatusError") + Expect(statusErr.Status().Message).Should(ContainSubstring("value must be empty when operator is Exists")) }) - It("should deny creation of CRP mixing NamespaceWithResourceSelectors with other namespace selectors", func() { - crp = placementv1beta1.ClusterResourcePlacement{ + It("must set a toleration operator to Exists when its key is empty", func() { + rpName := fmt.Sprintf(rpNameTemplate, GinkgoParallelProcess()) + + rp := &placementv1beta1.ResourcePlacement{ ObjectMeta: metav1.ObjectMeta{ - Name: crpName, + Name: rpName, + Namespace: rpNamespace, }, Spec: placementv1beta1.PlacementSpec{ ResourceSelectors: []placementv1beta1.ResourceSelectorTerm{ - { - Group: "", - Version: "v1", - Kind: "Namespace", - Name: "prod", - SelectionScope: placementv1beta1.NamespaceWithResourceSelectors, - }, { Group: "", Version: "v1", - Kind: "Namespace", - Name: "staging", - // Default SelectionScope is NamespaceWithResources + Kind: "ConfigMap", + Name: nonExistentCMName, + }, + }, + Policy: &placementv1beta1.PlacementPolicy{ + PlacementType: placementv1beta1.PickAllPlacementType, + Tolerations: []placementv1beta1.Toleration{ + { + Operator: corev1.TolerationOpEqual, + Value: "bar", + Effect: corev1.TaintEffectNoSchedule, + }, }, }, }, } - err := hubClient.Create(ctx, &crp) + + err := hubClient.Create(ctx, rp) + Expect(err).To(HaveOccurred(), "Expected error when creating RP with an empty toleration key and operator Equal") var statusErr *k8sErrors.StatusError - Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Create CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) - Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("when using NamespaceWithResourceSelectors mode, only one namespace selector is allowed")) + Expect(errors.As(err, &statusErr)).To(BeTrue(), "The returned error is not a StatusError") + Expect(statusErr.Status().Message).Should(ContainSubstring("operator must be Exists when key is empty")) }) }) - Context("Test ResourcePlacement API validation - invalid cases", func() { + Context("Test ResourcePlacement API validation - invalid update cases", func() { var rp placementv1beta1.ResourcePlacement rpName := fmt.Sprintf(rpNameTemplate, GinkgoParallelProcess()) @@ -1776,7 +2665,10 @@ var _ = Describe("Test placement v1beta1 API validation", func() { err := hubClient.Create(ctx, &strategy) var statusErr *k8sErrors.StatusError Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Create updateRunStrategy call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) - Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("Too long: may not be more than 63 bytes")) + // The error message might vary depending on the Kubernetes version (`Too long: may not be more than 63 bytes` or + // `Too long: may not be longer than 63`); for simplicity reasons, the check only verifies the `Too long` part + // of the message. + Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("Too long")) }) It("Should deny creation of ClusterStagedUpdateStrategy with invalid stage config - stage name with invalid characters", func() { diff --git a/test/e2e/webhook_test.go b/test/e2e/webhook_test.go index 326d08c4a..65a6825ce 100644 --- a/test/e2e/webhook_test.go +++ b/test/e2e/webhook_test.go @@ -56,7 +56,11 @@ var _ = Describe("webhook tests for CRP CREATE operations", func() { err := hubClient.Create(ctx, crp) var statusErr *k8sErrors.StatusError Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Create CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) - Expect(statusErr.Status().Message).Should(ContainSubstring("the labelSelector and name fields are mutually exclusive in selector")) + // The API new features CEL-based validation, which is typically triggered before the webhook runs. + // As a result, we no longer check the error message here any more, as the message would be formatted + // based on the CEL validation rule, not the webhook logic. + // + // TO-DO (chenyu1): drop this test case once the migration to CEL-based validation is complete. }) It("should deny create on CRP with invalid placement policy for PickFixed", func() { @@ -77,12 +81,16 @@ var _ = Describe("webhook tests for CRP CREATE operations", func() { err := hubClient.Create(ctx, &crp) var statusErr *k8sErrors.StatusError g.Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Create CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) - g.Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("number of clusters must be nil for policy type PickFixed")) + // The API new features CEL-based validation, which is typically triggered before the webhook runs. + // As a result, we no longer check the error message here any more, as the message would be formatted + // based on the CEL validation rule, not the webhook logic. + // + // TO-DO (chenyu1): drop this test case once the migration to CEL-based validation is complete. return nil }, eventuallyDuration, eventuallyInterval).Should(Succeed()) }) - It("should deny create on CRP with invalid placement policy for PickN", func() { + It("should deny create on CRP with invalid placement policy for PickN (ill-formed label selector)", func() { Eventually(func(g Gomega) error { crp := placementv1beta1.ClusterResourcePlacement{ ObjectMeta: metav1.ObjectMeta{ @@ -91,7 +99,8 @@ var _ = Describe("webhook tests for CRP CREATE operations", func() { Spec: placementv1beta1.PlacementSpec{ ResourceSelectors: workResourceSelector(), Policy: &placementv1beta1.PlacementPolicy{ - PlacementType: placementv1beta1.PickNPlacementType, + PlacementType: placementv1beta1.PickNPlacementType, + NumberOfClusters: ptr.To(int32(1)), Affinity: &placementv1beta1.Affinity{ ClusterAffinity: &placementv1beta1.ClusterAffinity{ PreferredDuringSchedulingIgnoredDuringExecution: []placementv1beta1.PreferredClusterSelector{ @@ -113,7 +122,7 @@ var _ = Describe("webhook tests for CRP CREATE operations", func() { TopologySpreadConstraints: []placementv1beta1.TopologySpreadConstraint{ { TopologyKey: "test-key", - WhenUnsatisfiable: "random-type", + WhenUnsatisfiable: placementv1beta1.DoNotSchedule, }, }, }, @@ -123,8 +132,6 @@ var _ = Describe("webhook tests for CRP CREATE operations", func() { var statusErr *k8sErrors.StatusError g.Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Create CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) g.Expect(statusErr.ErrStatus.Message).Should(MatchRegexp(regexp.QuoteMeta(fmt.Sprintf("the labelSelector in preferred cluster selector %+v is invalid:", crp.Spec.Policy.Affinity.ClusterAffinity.PreferredDuringSchedulingIgnoredDuringExecution[0].Preference.LabelSelector)))) - g.Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("unknown unsatisfiable type random-type")) - g.Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("number of cluster cannot be nil for policy type PickN")) return nil }, eventuallyDuration, eventuallyInterval).Should(Succeed()) }) @@ -438,7 +445,11 @@ var _ = Describe("webhook tests for CRP UPDATE operations", Ordered, func() { } var statusErr *k8sErrors.StatusError g.Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Update CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) - g.Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("the labelSelector and name fields are mutually exclusive")) + // The API new features CEL-based validation, which is typically triggered before the webhook runs. + // As a result, we no longer check the error message here any more, as the message would be formatted + // based on the CEL validation rule, not the webhook logic. + // + // TO-DO (chenyu1): drop this test case once the migration to CEL-based validation is complete. return nil }, eventuallyDuration, eventuallyInterval).Should(Succeed()) }) @@ -467,11 +478,6 @@ var _ = Describe("webhook tests for CRP UPDATE operations", Ordered, func() { }, }, }, - TopologySpreadConstraints: []placementv1beta1.TopologySpreadConstraint{ - { - TopologyKey: "test-key", - }, - }, } err := hubClient.Update(ctx, &crp) if k8sErrors.IsConflict(err) { @@ -480,7 +486,6 @@ var _ = Describe("webhook tests for CRP UPDATE operations", Ordered, func() { var statusErr *k8sErrors.StatusError g.Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Update CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) g.Expect(statusErr.ErrStatus.Message).Should(MatchRegexp(regexp.QuoteMeta(fmt.Sprintf("the labelSelector in cluster selector %+v is invalid:", crp.Spec.Policy.Affinity.ClusterAffinity.RequiredDuringSchedulingIgnoredDuringExecution.ClusterSelectorTerms[0].LabelSelector)))) - g.Expect(statusErr.ErrStatus.Message).Should(MatchRegexp("topology spread constraints needs to be empty for policy type PickAll")) return nil }, eventuallyDuration, eventuallyInterval).Should(Succeed()) }) @@ -728,7 +733,11 @@ var _ = Describe("webhook tests for CRP tolerations", Ordered, func() { } var statusErr *k8sErrors.StatusError g.Expect(errors.As(err, &statusErr)).To(BeTrue(), fmt.Sprintf("Update CRP call produced error %s. Error type wanted is %s.", reflect.TypeOf(err), reflect.TypeOf(&k8sErrors.StatusError{}))) - g.Expect(statusErr.ErrStatus.Message).Should(MatchRegexp(fmt.Sprintf("invalid toleration %+v: %s", invalidToleration, "toleration key cannot be empty, when operator is Equal"))) + // The API new features CEL-based validation, which is typically triggered before the webhook runs. + // As a result, we no longer check the error message here any more, as the message would be formatted + // based on the CEL validation rule, not the webhook logic. + // + // TO-DO (chenyu1): drop this test case once the migration to CEL-based validation is complete. return nil }, eventuallyDuration, eventuallyInterval).Should(Succeed()) })