diff --git a/apis/apiextensions/v1/composition_transforms.go b/apis/apiextensions/v1/composition_transforms.go index 551b719f7..b2452a712 100644 --- a/apis/apiextensions/v1/composition_transforms.go +++ b/apis/apiextensions/v1/composition_transforms.go @@ -445,12 +445,15 @@ const ( TransformIOTypeInt TransformIOType = "int" TransformIOTypeInt64 TransformIOType = "int64" TransformIOTypeFloat64 TransformIOType = "float64" + + TransformIOTypeObject TransformIOType = "object" + TransformIOTypeArray TransformIOType = "array" ) // IsValid checks if the given TransformIOType is valid. func (c TransformIOType) IsValid() bool { switch c { - case TransformIOTypeString, TransformIOTypeBool, TransformIOTypeInt, TransformIOTypeInt64, TransformIOTypeFloat64: + case TransformIOTypeString, TransformIOTypeBool, TransformIOTypeInt, TransformIOTypeInt64, TransformIOTypeFloat64, TransformIOTypeObject, TransformIOTypeArray: return true } return false @@ -464,12 +467,13 @@ type ConvertTransformFormat string const ( ConvertTransformFormatNone ConvertTransformFormat = "none" ConvertTransformFormatQuantity ConvertTransformFormat = "quantity" + ConvertTransformFormatJSON ConvertTransformFormat = "json" ) // IsValid returns true if the format is valid. func (c ConvertTransformFormat) IsValid() bool { switch c { - case ConvertTransformFormatNone, ConvertTransformFormatQuantity: + case ConvertTransformFormatNone, ConvertTransformFormatQuantity, ConvertTransformFormatJSON: return true } return false @@ -478,17 +482,19 @@ func (c ConvertTransformFormat) IsValid() bool { // A ConvertTransform converts the input into a new object whose type is supplied. type ConvertTransform struct { // ToType is the type of the output of this transform. - // +kubebuilder:validation:Enum=string;int;int64;bool;float64 + // +kubebuilder:validation:Enum=string;int;int64;bool;float64;object;list ToType TransformIOType `json:"toType"` // The expected input format. // // * `quantity` - parses the input as a K8s [`resource.Quantity`](https://pkg.go.dev/k8s.io/apimachinery/pkg/api/resource#Quantity). // Only used during `string -> float64` conversions. + // * `json` - parses the input as a JSON string. + // Only used during `string -> object` or `string -> list` conversions. // // If this property is null, the default conversion is applied. // - // +kubebuilder:validation:Enum=none;quantity + // +kubebuilder:validation:Enum=none;quantity;json // +kubebuilder:validation:Default=none Format *ConvertTransformFormat `json:"format,omitempty"` } diff --git a/apis/apiextensions/v1alpha1/register.go b/apis/apiextensions/v1alpha1/register.go index 881865396..948e0e0e8 100644 --- a/apis/apiextensions/v1alpha1/register.go +++ b/apis/apiextensions/v1alpha1/register.go @@ -48,6 +48,15 @@ var ( EnvironmentConfigGroupVersionKind = SchemeGroupVersion.WithKind(EnvironmentConfigKind) ) +// Usage type metadata. +var ( + UsageKind = reflect.TypeOf(Usage{}).Name() + UsageGroupKind = schema.GroupKind{Group: Group, Kind: UsageKind}.String() + UsageKindAPIVersion = UsageKind + "." + SchemeGroupVersion.String() + UsageGroupVersionKind = SchemeGroupVersion.WithKind(UsageKind) +) + func init() { SchemeBuilder.Register(&EnvironmentConfig{}, &EnvironmentConfigList{}) + SchemeBuilder.Register(&Usage{}, &UsageList{}) } diff --git a/apis/apiextensions/v1alpha1/usage_types.go b/apis/apiextensions/v1alpha1/usage_types.go new file mode 100644 index 000000000..f590b4a10 --- /dev/null +++ b/apis/apiextensions/v1alpha1/usage_types.go @@ -0,0 +1,101 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1" +) + +// ResourceRef is a reference to a resource. +type ResourceRef struct { + // Name of the referent. + Name string `json:"name"` +} + +// ResourceSelector is a selector to a resource. +type ResourceSelector struct { + // MatchLabels ensures an object with matching labels is selected. + MatchLabels map[string]string `json:"matchLabels,omitempty"` + + // MatchControllerRef ensures an object with the same controller reference + // as the selecting object is selected. + MatchControllerRef *bool `json:"matchControllerRef,omitempty"` +} + +// Resource defines a cluster-scoped resource. +type Resource struct { + // API version of the referent. + // +optional + APIVersion string `json:"apiVersion,omitempty"` + // Kind of the referent. + // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + // +optional + Kind string `json:"kind,omitempty"` + // Reference to the resource. + // +optional + ResourceRef *ResourceRef `json:"resourceRef,omitempty"` + // Selector to the resource. + // This field will be ignored if ResourceRef is set. + // +optional + ResourceSelector *ResourceSelector `json:"resourceSelector,omitempty"` +} + +// UsageSpec defines the desired state of Usage. +type UsageSpec struct { + // Of is the resource that is "being used". + // +kubebuilder:validation:XValidation:rule="has(self.resourceRef) || has(self.resourceSelector)",message="either a resource reference or a resource selector should be set." + Of Resource `json:"of"` + // By is the resource that is "using the other resource". + // +optional + // +kubebuilder:validation:XValidation:rule="has(self.resourceRef) || has(self.resourceSelector)",message="either a resource reference or a resource selector should be set." + By *Resource `json:"by,omitempty"` + // Reason is the reason for blocking deletion of the resource. + // +optional + Reason *string `json:"reason,omitempty"` +} + +// UsageStatus defines the observed state of Usage. +type UsageStatus struct { + xpv1.ConditionedStatus `json:",inline"` +} + +// A Usage defines a deletion blocking relationship between two resources. +// +kubebuilder:object:root=true +// +kubebuilder:storageversion +// +kubebuilder:printcolumn:name="DETAILS",type="string",JSONPath=".metadata.annotations.crossplane\\.io/usage-details" +// +kubebuilder:printcolumn:name="READY",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status" +// +kubebuilder:printcolumn:name="AGE",type="date",JSONPath=".metadata.creationTimestamp" +// +kubebuilder:resource:scope=Cluster,categories=crossplane +// +kubebuilder:subresource:status +type Usage struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + // +kubebuilder:validation:XValidation:rule="has(self.by) || has(self.reason)",message="either \"spec.by\" or \"spec.reason\" must be specified." + Spec UsageSpec `json:"spec"` + Status UsageStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// UsageList contains a list of Usage. +type UsageList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Usage `json:"items"` +} diff --git a/apis/apiextensions/v1alpha1/zz_generated.deepcopy.go b/apis/apiextensions/v1alpha1/zz_generated.deepcopy.go index 88591a50a..44580d92d 100644 --- a/apis/apiextensions/v1alpha1/zz_generated.deepcopy.go +++ b/apis/apiextensions/v1alpha1/zz_generated.deepcopy.go @@ -89,3 +89,171 @@ func (in *EnvironmentConfigList) DeepCopyObject() runtime.Object { } return nil } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Resource) DeepCopyInto(out *Resource) { + *out = *in + if in.ResourceRef != nil { + in, out := &in.ResourceRef, &out.ResourceRef + *out = new(ResourceRef) + **out = **in + } + if in.ResourceSelector != nil { + in, out := &in.ResourceSelector, &out.ResourceSelector + *out = new(ResourceSelector) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Resource. +func (in *Resource) DeepCopy() *Resource { + if in == nil { + return nil + } + out := new(Resource) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResourceRef) DeepCopyInto(out *ResourceRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceRef. +func (in *ResourceRef) DeepCopy() *ResourceRef { + if in == nil { + return nil + } + out := new(ResourceRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResourceSelector) DeepCopyInto(out *ResourceSelector) { + *out = *in + if in.MatchLabels != nil { + in, out := &in.MatchLabels, &out.MatchLabels + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.MatchControllerRef != nil { + in, out := &in.MatchControllerRef, &out.MatchControllerRef + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceSelector. +func (in *ResourceSelector) DeepCopy() *ResourceSelector { + if in == nil { + return nil + } + out := new(ResourceSelector) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Usage) DeepCopyInto(out *Usage) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Usage. +func (in *Usage) DeepCopy() *Usage { + if in == nil { + return nil + } + out := new(Usage) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Usage) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UsageList) DeepCopyInto(out *UsageList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Usage, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UsageList. +func (in *UsageList) DeepCopy() *UsageList { + if in == nil { + return nil + } + out := new(UsageList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *UsageList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UsageSpec) DeepCopyInto(out *UsageSpec) { + *out = *in + in.Of.DeepCopyInto(&out.Of) + if in.By != nil { + in, out := &in.By, &out.By + *out = new(Resource) + (*in).DeepCopyInto(*out) + } + if in.Reason != nil { + in, out := &in.Reason, &out.Reason + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UsageSpec. +func (in *UsageSpec) DeepCopy() *UsageSpec { + if in == nil { + return nil + } + out := new(UsageSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UsageStatus) DeepCopyInto(out *UsageStatus) { + *out = *in + in.ConditionedStatus.DeepCopyInto(&out.ConditionedStatus) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UsageStatus. +func (in *UsageStatus) DeepCopy() *UsageStatus { + if in == nil { + return nil + } + out := new(UsageStatus) + in.DeepCopyInto(out) + return out +} diff --git a/apis/apiextensions/v1beta1/zz_generated.composition_transforms.go b/apis/apiextensions/v1beta1/zz_generated.composition_transforms.go index 2c1a2639f..a558ad0b8 100644 --- a/apis/apiextensions/v1beta1/zz_generated.composition_transforms.go +++ b/apis/apiextensions/v1beta1/zz_generated.composition_transforms.go @@ -447,12 +447,15 @@ const ( TransformIOTypeInt TransformIOType = "int" TransformIOTypeInt64 TransformIOType = "int64" TransformIOTypeFloat64 TransformIOType = "float64" + + TransformIOTypeObject TransformIOType = "object" + TransformIOTypeArray TransformIOType = "array" ) // IsValid checks if the given TransformIOType is valid. func (c TransformIOType) IsValid() bool { switch c { - case TransformIOTypeString, TransformIOTypeBool, TransformIOTypeInt, TransformIOTypeInt64, TransformIOTypeFloat64: + case TransformIOTypeString, TransformIOTypeBool, TransformIOTypeInt, TransformIOTypeInt64, TransformIOTypeFloat64, TransformIOTypeObject, TransformIOTypeArray: return true } return false @@ -466,12 +469,13 @@ type ConvertTransformFormat string const ( ConvertTransformFormatNone ConvertTransformFormat = "none" ConvertTransformFormatQuantity ConvertTransformFormat = "quantity" + ConvertTransformFormatJSON ConvertTransformFormat = "json" ) // IsValid returns true if the format is valid. func (c ConvertTransformFormat) IsValid() bool { switch c { - case ConvertTransformFormatNone, ConvertTransformFormatQuantity: + case ConvertTransformFormatNone, ConvertTransformFormatQuantity, ConvertTransformFormatJSON: return true } return false @@ -480,17 +484,19 @@ func (c ConvertTransformFormat) IsValid() bool { // A ConvertTransform converts the input into a new object whose type is supplied. type ConvertTransform struct { // ToType is the type of the output of this transform. - // +kubebuilder:validation:Enum=string;int;int64;bool;float64 + // +kubebuilder:validation:Enum=string;int;int64;bool;float64;object;list ToType TransformIOType `json:"toType"` // The expected input format. // // * `quantity` - parses the input as a K8s [`resource.Quantity`](https://pkg.go.dev/k8s.io/apimachinery/pkg/api/resource#Quantity). // Only used during `string -> float64` conversions. + // * `json` - parses the input as a JSON string. + // Only used during `string -> object` or `string -> list` conversions. // // If this property is null, the default conversion is applied. // - // +kubebuilder:validation:Enum=none;quantity + // +kubebuilder:validation:Enum=none;quantity;json // +kubebuilder:validation:Default=none Format *ConvertTransformFormat `json:"format,omitempty"` } diff --git a/apis/generate.go b/apis/generate.go index 576f2c3e6..eaf424035 100644 --- a/apis/generate.go +++ b/apis/generate.go @@ -22,7 +22,7 @@ limitations under the License. // Remove existing manifests //go:generate rm -rf ../cluster/crds -//go:generate rm -rf ../cluster/webhookconfigurations +//go:generate rm -rf ../cluster/webhookconfigurations/manifests.yaml // Replicate identical API versions diff --git a/cluster/crds/apiextensions.crossplane.io_compositionrevisions.yaml b/cluster/crds/apiextensions.crossplane.io_compositionrevisions.yaml index d9947e7f2..7497727f1 100644 --- a/cluster/crds/apiextensions.crossplane.io_compositionrevisions.yaml +++ b/cluster/crds/apiextensions.crossplane.io_compositionrevisions.yaml @@ -264,11 +264,14 @@ spec: description: "The expected input format. \n * `quantity` - parses the input as a K8s [`resource.Quantity`](https://pkg.go.dev/k8s.io/apimachinery/pkg/api/resource#Quantity). Only used during `string -> float64` conversions. - \n If this property is null, the default conversion - is applied." + * `json` - parses the input as a JSON string. + Only used during `string -> object` or `string + -> list` conversions. \n If this property is + null, the default conversion is applied." enum: - none - quantity + - json type: string toType: description: ToType is the type of the output @@ -279,6 +282,8 @@ spec: - int64 - bool - float64 + - object + - list type: string required: - toType @@ -748,11 +753,14 @@ spec: description: "The expected input format. \n * `quantity` - parses the input as a K8s [`resource.Quantity`](https://pkg.go.dev/k8s.io/apimachinery/pkg/api/resource#Quantity). Only used during `string -> float64` conversions. - \n If this property is null, the default conversion - is applied." + * `json` - parses the input as a JSON string. + Only used during `string -> object` or `string + -> list` conversions. \n If this property + is null, the default conversion is applied." enum: - none - quantity + - json type: string toType: description: ToType is the type of the output @@ -763,6 +771,8 @@ spec: - int64 - bool - float64 + - object + - list type: string required: - toType @@ -1163,11 +1173,14 @@ spec: description: "The expected input format. \n * `quantity` - parses the input as a K8s [`resource.Quantity`](https://pkg.go.dev/k8s.io/apimachinery/pkg/api/resource#Quantity). Only used during `string -> float64` conversions. - \n If this property is null, the default conversion - is applied." + * `json` - parses the input as a JSON string. + Only used during `string -> object` or `string + -> list` conversions. \n If this property + is null, the default conversion is applied." enum: - none - quantity + - json type: string toType: description: ToType is the type of the output @@ -1178,6 +1191,8 @@ spec: - int64 - bool - float64 + - object + - list type: string required: - toType @@ -1742,11 +1757,14 @@ spec: description: "The expected input format. \n * `quantity` - parses the input as a K8s [`resource.Quantity`](https://pkg.go.dev/k8s.io/apimachinery/pkg/api/resource#Quantity). Only used during `string -> float64` conversions. - \n If this property is null, the default conversion - is applied." + * `json` - parses the input as a JSON string. + Only used during `string -> object` or `string + -> list` conversions. \n If this property is + null, the default conversion is applied." enum: - none - quantity + - json type: string toType: description: ToType is the type of the output @@ -1757,6 +1775,8 @@ spec: - int64 - bool - float64 + - object + - list type: string required: - toType @@ -2226,11 +2246,14 @@ spec: description: "The expected input format. \n * `quantity` - parses the input as a K8s [`resource.Quantity`](https://pkg.go.dev/k8s.io/apimachinery/pkg/api/resource#Quantity). Only used during `string -> float64` conversions. - \n If this property is null, the default conversion - is applied." + * `json` - parses the input as a JSON string. + Only used during `string -> object` or `string + -> list` conversions. \n If this property + is null, the default conversion is applied." enum: - none - quantity + - json type: string toType: description: ToType is the type of the output @@ -2241,6 +2264,8 @@ spec: - int64 - bool - float64 + - object + - list type: string required: - toType @@ -2641,11 +2666,14 @@ spec: description: "The expected input format. \n * `quantity` - parses the input as a K8s [`resource.Quantity`](https://pkg.go.dev/k8s.io/apimachinery/pkg/api/resource#Quantity). Only used during `string -> float64` conversions. - \n If this property is null, the default conversion - is applied." + * `json` - parses the input as a JSON string. + Only used during `string -> object` or `string + -> list` conversions. \n If this property + is null, the default conversion is applied." enum: - none - quantity + - json type: string toType: description: ToType is the type of the output @@ -2656,6 +2684,8 @@ spec: - int64 - bool - float64 + - object + - list type: string required: - toType diff --git a/cluster/crds/apiextensions.crossplane.io_compositions.yaml b/cluster/crds/apiextensions.crossplane.io_compositions.yaml index aaf2bcd3f..141428638 100644 --- a/cluster/crds/apiextensions.crossplane.io_compositions.yaml +++ b/cluster/crds/apiextensions.crossplane.io_compositions.yaml @@ -261,11 +261,14 @@ spec: description: "The expected input format. \n * `quantity` - parses the input as a K8s [`resource.Quantity`](https://pkg.go.dev/k8s.io/apimachinery/pkg/api/resource#Quantity). Only used during `string -> float64` conversions. - \n If this property is null, the default conversion - is applied." + * `json` - parses the input as a JSON string. + Only used during `string -> object` or `string + -> list` conversions. \n If this property is + null, the default conversion is applied." enum: - none - quantity + - json type: string toType: description: ToType is the type of the output @@ -276,6 +279,8 @@ spec: - int64 - bool - float64 + - object + - list type: string required: - toType @@ -748,11 +753,14 @@ spec: description: "The expected input format. \n * `quantity` - parses the input as a K8s [`resource.Quantity`](https://pkg.go.dev/k8s.io/apimachinery/pkg/api/resource#Quantity). Only used during `string -> float64` conversions. - \n If this property is null, the default conversion - is applied." + * `json` - parses the input as a JSON string. + Only used during `string -> object` or `string + -> list` conversions. \n If this property + is null, the default conversion is applied." enum: - none - quantity + - json type: string toType: description: ToType is the type of the output @@ -763,6 +771,8 @@ spec: - int64 - bool - float64 + - object + - list type: string required: - toType @@ -1167,11 +1177,14 @@ spec: description: "The expected input format. \n * `quantity` - parses the input as a K8s [`resource.Quantity`](https://pkg.go.dev/k8s.io/apimachinery/pkg/api/resource#Quantity). Only used during `string -> float64` conversions. - \n If this property is null, the default conversion - is applied." + * `json` - parses the input as a JSON string. + Only used during `string -> object` or `string + -> list` conversions. \n If this property + is null, the default conversion is applied." enum: - none - quantity + - json type: string toType: description: ToType is the type of the output @@ -1182,6 +1195,8 @@ spec: - int64 - bool - float64 + - object + - list type: string required: - toType diff --git a/cluster/crds/apiextensions.crossplane.io_usages.yaml b/cluster/crds/apiextensions.crossplane.io_usages.yaml new file mode 100644 index 000000000..7596123a4 --- /dev/null +++ b/cluster/crds/apiextensions.crossplane.io_usages.yaml @@ -0,0 +1,177 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.12.1 + name: usages.apiextensions.crossplane.io +spec: + group: apiextensions.crossplane.io + names: + categories: + - crossplane + kind: Usage + listKind: UsageList + plural: usages + singular: usage + scope: Cluster + versions: + - additionalPrinterColumns: + - jsonPath: .metadata.annotations.crossplane\.io/usage-details + name: DETAILS + type: string + - jsonPath: .status.conditions[?(@.type=='Ready')].status + name: READY + type: string + - jsonPath: .metadata.creationTimestamp + name: AGE + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: A Usage defines a deletion blocking relationship between two + resources. + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + description: UsageSpec defines the desired state of Usage. + properties: + by: + description: By is the resource that is "using the other resource". + properties: + apiVersion: + description: API version of the referent. + type: string + kind: + description: 'Kind of the referent. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + resourceRef: + description: Reference to the resource. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + resourceSelector: + description: Selector to the resource. This field will be ignored + if ResourceRef is set. + properties: + matchControllerRef: + description: MatchControllerRef ensures an object with the + same controller reference as the selecting object is selected. + type: boolean + matchLabels: + additionalProperties: + type: string + description: MatchLabels ensures an object with matching labels + is selected. + type: object + type: object + type: object + x-kubernetes-validations: + - message: either a resource reference or a resource selector should + be set. + rule: has(self.resourceRef) || has(self.resourceSelector) + of: + description: Of is the resource that is "being used". + properties: + apiVersion: + description: API version of the referent. + type: string + kind: + description: 'Kind of the referent. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + resourceRef: + description: Reference to the resource. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + resourceSelector: + description: Selector to the resource. This field will be ignored + if ResourceRef is set. + properties: + matchControllerRef: + description: MatchControllerRef ensures an object with the + same controller reference as the selecting object is selected. + type: boolean + matchLabels: + additionalProperties: + type: string + description: MatchLabels ensures an object with matching labels + is selected. + type: object + type: object + type: object + x-kubernetes-validations: + - message: either a resource reference or a resource selector should + be set. + rule: has(self.resourceRef) || has(self.resourceSelector) + reason: + description: Reason is the reason for blocking deletion of the resource. + type: string + required: + - of + type: object + x-kubernetes-validations: + - message: either "spec.by" or "spec.reason" must be specified. + rule: has(self.by) || has(self.reason) + status: + description: UsageStatus defines the observed state of Usage. + properties: + conditions: + description: Conditions of the resource. + items: + description: A Condition that may apply to a resource. + properties: + lastTransitionTime: + description: LastTransitionTime is the last time this condition + transitioned from one status to another. + format: date-time + type: string + message: + description: A Message containing details about this condition's + last transition from one status to another, if any. + type: string + reason: + description: A Reason for this condition's last transition from + one status to another. + type: string + status: + description: Status of this condition; is it currently True, + False, or Unknown? + type: string + type: + description: Type of this condition. At most one of each condition + type may apply to a resource at any point in time. + type: string + required: + - lastTransitionTime + - reason + - status + - type + type: object + type: array + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/cluster/kustomization.yaml b/cluster/kustomization.yaml index 95ac09ad3..a6eece6a5 100644 --- a/cluster/kustomization.yaml +++ b/cluster/kustomization.yaml @@ -5,6 +5,7 @@ resources: - crds/apiextensions.crossplane.io_compositionrevisions.yaml - crds/apiextensions.crossplane.io_compositions.yaml - crds/apiextensions.crossplane.io_environmentconfigs.yaml +- crds/apiextensions.crossplane.io_usages.yaml - crds/pkg.crossplane.io_configurationrevisions.yaml - crds/pkg.crossplane.io_configurations.yaml - crds/pkg.crossplane.io_controllerconfigs.yaml diff --git a/cluster/webhookconfigurations/usage.yaml b/cluster/webhookconfigurations/usage.yaml new file mode 100644 index 000000000..44c8674bb --- /dev/null +++ b/cluster/webhookconfigurations/usage.yaml @@ -0,0 +1,31 @@ +--- +# Note(turkenh): It is not possible to get this generated by kubebuilder at the moment due to +# lack of support for objectSelector in controller-tools. +# See: https://github.com/kubernetes-sigs/controller-tools/blob/master/pkg/webhook/parser.go#L202-L212 +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + name: crossplane-no-usages +webhooks: + - admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /validate-no-usages + failurePolicy: Fail + name: nousages.apiextensions.crossplane.io + objectSelector: + matchLabels: + crossplane.io/in-use: "true" + rules: + - apiGroups: + - '*' + apiVersions: + - '*' + operations: + - DELETE + resources: + - '*' + sideEffects: None \ No newline at end of file diff --git a/cmd/crossplane/core/core.go b/cmd/crossplane/core/core.go index e930264d1..7f2444ff6 100644 --- a/cmd/crossplane/core/core.go +++ b/cmd/crossplane/core/core.go @@ -49,6 +49,7 @@ import ( "github.com/crossplane/crossplane/internal/initializer" "github.com/crossplane/crossplane/internal/oci" "github.com/crossplane/crossplane/internal/transport" + "github.com/crossplane/crossplane/internal/usage" "github.com/crossplane/crossplane/internal/validation/apiextensions/v1/composition" "github.com/crossplane/crossplane/internal/xpkg" ) @@ -98,6 +99,7 @@ type startCommand struct { EnableExternalSecretStores bool `group:"Alpha Features:" help:"Enable support for External Secret Stores."` EnableCompositionFunctions bool `group:"Alpha Features:" help:"Enable support for Composition Functions."` EnableCompositionWebhookSchemaValidation bool `group:"Alpha Features:" help:"Enable support for Composition validation using schemas."` + EnableUsages bool `group:"Alpha Features:" help:"Enable support for deletion ordering and resource protection with Usages."` // These are GA features that previously had alpha or beta feature flags. // You can't turn off a GA feature. We maintain the flags to avoid breaking @@ -189,6 +191,10 @@ func (c *startCommand) Run(s *runtime.Scheme, log logging.Logger) error { //noli feats.Enable(features.EnableAlphaCompositionWebhookSchemaValidation) log.Info("Alpha feature enabled", "flag", features.EnableAlphaCompositionWebhookSchemaValidation) } + if c.EnableUsages { + feats.Enable(features.EnableAlphaUsages) + log.Info("Alpha feature enabled", "flag", features.EnableAlphaUsages) + } if !c.EnableCompositionRevisions { log.Info("CompositionRevisions feature is GA and cannot be disabled. The --enable-composition-revisions flag will be removed in a future release.") } @@ -268,6 +274,11 @@ func (c *startCommand) Run(s *runtime.Scheme, log logging.Logger) error { //noli if err := composition.SetupWebhookWithManager(mgr, o); err != nil { return errors.Wrap(err, "cannot setup webhook for compositions") } + if o.Features.Enabled(features.EnableAlphaUsages) { + if err := usage.SetupWebhookWithManager(mgr, o); err != nil { + return errors.Wrap(err, "cannot setup webhook for usages") + } + } } return errors.Wrap(mgr.Start(ctrl.SetupSignalHandler()), "Cannot start controller manager") diff --git a/internal/controller/apiextensions/apiextensions.go b/internal/controller/apiextensions/apiextensions.go index f687dafd2..fb0752c2d 100644 --- a/internal/controller/apiextensions/apiextensions.go +++ b/internal/controller/apiextensions/apiextensions.go @@ -24,6 +24,8 @@ import ( "github.com/crossplane/crossplane/internal/controller/apiextensions/controller" "github.com/crossplane/crossplane/internal/controller/apiextensions/definition" "github.com/crossplane/crossplane/internal/controller/apiextensions/offered" + "github.com/crossplane/crossplane/internal/controller/apiextensions/usage" + "github.com/crossplane/crossplane/internal/features" ) // Setup API extensions controllers. @@ -36,5 +38,11 @@ func Setup(mgr ctrl.Manager, o controller.Options) error { return err } + if o.Features.Enabled(features.EnableAlphaUsages) { + if err := usage.Setup(mgr, o); err != nil { + return err + } + } + return offered.Setup(mgr, o) } diff --git a/internal/controller/apiextensions/composite/composition_pt.go b/internal/controller/apiextensions/composite/composition_pt.go index d0c13ea27..833a8fd84 100644 --- a/internal/controller/apiextensions/composite/composition_pt.go +++ b/internal/controller/apiextensions/composite/composition_pt.go @@ -38,6 +38,7 @@ import ( v1 "github.com/crossplane/crossplane/apis/apiextensions/v1" env "github.com/crossplane/crossplane/internal/controller/apiextensions/composite/environment" + "github.com/crossplane/crossplane/internal/controller/apiextensions/usage" "github.com/crossplane/crossplane/internal/xcrd" ) @@ -239,7 +240,7 @@ func (c *PTComposer) Compose(ctx context.Context, xr resource.Composite, req Com if cd.TemplateRenderErr != nil { continue } - o := []resource.ApplyOption{resource.MustBeControllableBy(xr.GetUID())} + o := []resource.ApplyOption{resource.MustBeControllableBy(xr.GetUID()), usage.RespectOwnerRefs()} o = append(o, mergeOptions(filterPatches(cd.Template.Patches, patchTypesFromXR()...))...) if err := c.client.Apply(ctx, cd.Resource, o...); err != nil { return CompositionResult{}, errors.Wrap(err, errApply) diff --git a/internal/controller/apiextensions/composite/composition_ptf.go b/internal/controller/apiextensions/composite/composition_ptf.go index 4dded326b..04ca7ed46 100644 --- a/internal/controller/apiextensions/composite/composition_ptf.go +++ b/internal/controller/apiextensions/composite/composition_ptf.go @@ -46,6 +46,7 @@ import ( iov1alpha1 "github.com/crossplane/crossplane/apis/apiextensions/fn/io/v1alpha1" fnv1alpha1 "github.com/crossplane/crossplane/apis/apiextensions/fn/proto/v1alpha1" v1 "github.com/crossplane/crossplane/apis/apiextensions/v1" + "github.com/crossplane/crossplane/internal/controller/apiextensions/usage" "github.com/crossplane/crossplane/internal/xcrd" ) @@ -367,7 +368,7 @@ func (c *PTFComposer) Compose(ctx context.Context, xr resource.Composite, req Co continue } - ao := []resource.ApplyOption{resource.MustBeControllableBy(state.Composite.GetUID())} + ao := []resource.ApplyOption{resource.MustBeControllableBy(state.Composite.GetUID()), usage.RespectOwnerRefs()} if cd.Template != nil { ao = append(ao, mergeOptions(filterPatches(cd.Template.Patches, patchTypesFromXR()...))...) } diff --git a/internal/controller/apiextensions/composite/composition_transforms.go b/internal/controller/apiextensions/composite/composition_transforms.go index 1da5f8c6f..7c4c05e6e 100644 --- a/internal/controller/apiextensions/composite/composition_transforms.go +++ b/internal/controller/apiextensions/composite/composition_transforms.go @@ -483,4 +483,12 @@ var conversions = map[conversionPair]func(any) (any, error){ {from: v1.TransformIOTypeFloat64, to: v1.TransformIOTypeBool, format: v1.ConvertTransformFormatNone}: func(i any) (any, error) { //nolint:unparam // See note above. return i.(float64) == float64(1), nil }, + {from: v1.TransformIOTypeString, to: v1.TransformIOTypeObject, format: v1.ConvertTransformFormatJSON}: func(i any) (any, error) { + o := map[string]any{} + return o, json.Unmarshal([]byte(i.(string)), &o) + }, + {from: v1.TransformIOTypeString, to: v1.TransformIOTypeArray, format: v1.ConvertTransformFormatJSON}: func(i any) (any, error) { + var o []any + return o, json.Unmarshal([]byte(i.(string)), &o) + }, } diff --git a/internal/controller/apiextensions/composite/composition_transforms_test.go b/internal/controller/apiextensions/composite/composition_transforms_test.go index ef929b1e6..900fc7f2b 100644 --- a/internal/controller/apiextensions/composite/composition_transforms_test.go +++ b/internal/controller/apiextensions/composite/composition_transforms_test.go @@ -1079,6 +1079,30 @@ func TestConvertResolve(t *testing.T) { o: int64(1), }, }, + "StringToObject": { + args: args{ + i: "{\"foo\":\"bar\"}", + to: v1.TransformIOTypeObject, + format: (*v1.ConvertTransformFormat)(pointer.String(string(v1.ConvertTransformFormatJSON))), + }, + want: want{ + o: map[string]any{ + "foo": "bar", + }, + }, + }, + "StringToList": { + args: args{ + i: "[\"foo\", \"bar\", \"baz\"]", + to: v1.TransformIOTypeArray, + format: (*v1.ConvertTransformFormat)(pointer.String(string(v1.ConvertTransformFormatJSON))), + }, + want: want{ + o: []any{ + "foo", "bar", "baz", + }, + }, + }, "InputTypeNotSupported": { args: args{ i: []int{64}, @@ -1204,6 +1228,38 @@ func TestConvertTransformGetConversionFunc(t *testing.T) { from: v1.TransformIOTypeBool, }, }, + "JSONStringToObject": { + reason: "JSON string to Object should be valid", + args: args{ + ct: &v1.ConvertTransform{ + ToType: v1.TransformIOTypeObject, + Format: &[]v1.ConvertTransformFormat{v1.ConvertTransformFormatJSON}[0], + }, + from: v1.TransformIOTypeString, + }, + }, + "JSONStringToArray": { + reason: "JSON string to Array should be valid", + args: args{ + ct: &v1.ConvertTransform{ + ToType: v1.TransformIOTypeArray, + Format: &[]v1.ConvertTransformFormat{v1.ConvertTransformFormatJSON}[0], + }, + from: v1.TransformIOTypeString, + }, + }, + "StringToObjectMissingFormat": { + reason: "String to Object without format should be invalid", + args: args{ + ct: &v1.ConvertTransform{ + ToType: v1.TransformIOTypeObject, + }, + from: v1.TransformIOTypeString, + }, + want: want{ + err: fmt.Errorf("conversion from string to object is not supported with format none"), + }, + }, "StringToIntInvalidFormat": { reason: "String to Int with invalid format should be invalid", args: args{ diff --git a/internal/controller/apiextensions/usage/composed/composed.go b/internal/controller/apiextensions/usage/composed/composed.go new file mode 100644 index 000000000..29f432578 --- /dev/null +++ b/internal/controller/apiextensions/usage/composed/composed.go @@ -0,0 +1,176 @@ +/* +Copyright 2020 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Note(turkenh): This file is copied from https://github.com/crossplane/crossplane-runtime/blob/9d37f2639b182b4da5f832f1467d831dfa68ff7f/pkg/resource/unstructured/composed/composed.go\ +// to be able to back-port the Usage PR here without having these changes +// in the release branch of crossplane-runtime. +// Ideally, we should maintain a fork of crossplane-runtime as +// upbound/crossplane-runtime and depend on it so that we can backport +// changes/fixes that depend on crossplane-runtime. +// Considering this is a rare requirement (encountered only once so far), +// we are using this as a workaround for now. + +// Package composed contains an unstructured composed resource. +package composed + +import ( + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" + + xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1" + "github.com/crossplane/crossplane-runtime/pkg/fieldpath" +) + +// An Option modifies an unstructured composed resource. +type Option func(resource *Unstructured) + +// FromReference returns an Option that propagates the metadata in the supplied +// reference to an unstructured composed resource. +func FromReference(ref corev1.ObjectReference) Option { + return func(cr *Unstructured) { + cr.SetGroupVersionKind(ref.GroupVersionKind()) + cr.SetName(ref.Name) + cr.SetNamespace(ref.Namespace) + cr.SetUID(ref.UID) + } +} + +// WithConditions returns an Option that sets the supplied conditions on an +// unstructured composed resource. +func WithConditions(c ...xpv1.Condition) Option { + return func(cr *Unstructured) { + cr.SetConditions(c...) + } +} + +// New returns a new unstructured composed resource. +func New(opts ...Option) *Unstructured { + cr := &Unstructured{unstructured.Unstructured{Object: make(map[string]any)}} + for _, f := range opts { + f(cr) + } + return cr +} + +// An Unstructured composed resource. +type Unstructured struct { + unstructured.Unstructured +} + +// GetUnstructured returns the underlying *unstructured.Unstructured. +func (cr *Unstructured) GetUnstructured() *unstructured.Unstructured { + return &cr.Unstructured +} + +// GetCondition of this Composed resource. +func (cr *Unstructured) GetCondition(ct xpv1.ConditionType) xpv1.Condition { + conditioned := xpv1.ConditionedStatus{} + // The path is directly `status` because conditions are inline. + if err := fieldpath.Pave(cr.Object).GetValueInto("status", &conditioned); err != nil { + return xpv1.Condition{} + } + return conditioned.GetCondition(ct) +} + +// SetConditions of this Composed resource. +func (cr *Unstructured) SetConditions(c ...xpv1.Condition) { + conditioned := xpv1.ConditionedStatus{} + // The path is directly `status` because conditions are inline. + _ = fieldpath.Pave(cr.Object).GetValueInto("status", &conditioned) + conditioned.SetConditions(c...) + _ = fieldpath.Pave(cr.Object).SetValue("status.conditions", conditioned.Conditions) +} + +// GetWriteConnectionSecretToReference of this Composed resource. +func (cr *Unstructured) GetWriteConnectionSecretToReference() *xpv1.SecretReference { + out := &xpv1.SecretReference{} + if err := fieldpath.Pave(cr.Object).GetValueInto("spec.writeConnectionSecretToRef", out); err != nil { + return nil + } + return out +} + +// SetWriteConnectionSecretToReference of this Composed resource. +func (cr *Unstructured) SetWriteConnectionSecretToReference(r *xpv1.SecretReference) { + _ = fieldpath.Pave(cr.Object).SetValue("spec.writeConnectionSecretToRef", r) +} + +// GetPublishConnectionDetailsTo of this Composed resource. +func (cr *Unstructured) GetPublishConnectionDetailsTo() *xpv1.PublishConnectionDetailsTo { + out := &xpv1.PublishConnectionDetailsTo{} + if err := fieldpath.Pave(cr.Object).GetValueInto("spec.publishConnectionDetailsTo", out); err != nil { + return nil + } + return out +} + +// SetPublishConnectionDetailsTo of this Composed resource. +func (cr *Unstructured) SetPublishConnectionDetailsTo(ref *xpv1.PublishConnectionDetailsTo) { + _ = fieldpath.Pave(cr.Object).SetValue("spec.publishConnectionDetailsTo", ref) +} + +// OwnedBy returns true if the supplied UID is an owner of the composed +func (cr *Unstructured) OwnedBy(u types.UID) bool { + for _, owner := range cr.GetOwnerReferences() { + if owner.UID == u { + return true + } + } + return false +} + +// RemoveOwnerRef removes the supplied UID from the composed resource's owner +func (cr *Unstructured) RemoveOwnerRef(u types.UID) { + refs := cr.GetOwnerReferences() + for i := range refs { + if refs[i].UID == u { + cr.SetOwnerReferences(append(refs[:i], refs[i+1:]...)) + return + } + } +} + +// An ListOption modifies an unstructured list of composed resource. +type ListOption func(*UnstructuredList) + +// FromReferenceToList returns a ListOption that propagates the metadata in the +// supplied reference to an unstructured list composed resource. +func FromReferenceToList(ref corev1.ObjectReference) ListOption { + return func(list *UnstructuredList) { + list.SetAPIVersion(ref.APIVersion) + list.SetKind(ref.Kind + "List") + } +} + +// NewList returns a new unstructured list of composed resources. +func NewList(opts ...ListOption) *UnstructuredList { + cr := &UnstructuredList{unstructured.UnstructuredList{Object: make(map[string]any)}} + for _, f := range opts { + f(cr) + } + return cr +} + +// An UnstructuredList of composed resources. +type UnstructuredList struct { + unstructured.UnstructuredList +} + +// GetUnstructuredList returns the underlying *unstructured.Unstructured. +func (cr *UnstructuredList) GetUnstructuredList() *unstructured.UnstructuredList { + return &cr.UnstructuredList +} diff --git a/internal/controller/apiextensions/usage/reconciler.go b/internal/controller/apiextensions/usage/reconciler.go new file mode 100644 index 000000000..96ed4e736 --- /dev/null +++ b/internal/controller/apiextensions/usage/reconciler.go @@ -0,0 +1,425 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package usage manages the lifecycle of usageResource objects. +package usage + +import ( + "context" + "fmt" + "strings" + "time" + + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1" + "github.com/crossplane/crossplane-runtime/pkg/errors" + "github.com/crossplane/crossplane-runtime/pkg/event" + "github.com/crossplane/crossplane-runtime/pkg/logging" + "github.com/crossplane/crossplane-runtime/pkg/meta" + "github.com/crossplane/crossplane-runtime/pkg/ratelimiter" + xpresource "github.com/crossplane/crossplane-runtime/pkg/resource" + "github.com/crossplane/crossplane-runtime/pkg/resource/unstructured" + + "github.com/crossplane/crossplane/apis/apiextensions/v1alpha1" + apiextensionscontroller "github.com/crossplane/crossplane/internal/controller/apiextensions/controller" + "github.com/crossplane/crossplane/internal/controller/apiextensions/usage/composed" + "github.com/crossplane/crossplane/internal/usage" +) + +const ( + reconcileTimeout = 1 * time.Minute + waitPollInterval = 30 * time.Second + finalizer = "usage.apiextensions.crossplane.io" + // Note(turkenh): In-use label enables the "DELETE" requests on resources + // with this label to be intercepted by the webhook and rejected if the + // resource is in use. + inUseLabelKey = "crossplane.io/in-use" + detailsAnnotationKey = "crossplane.io/usage-details" + + errGetUsage = "cannot get usage" + errResolveSelectors = "cannot resolve selectors" + errListUsages = "cannot list usages" + errGetUsing = "cannot get using" + errGetUsed = "cannot get used" + errAddOwnerToUsage = "cannot update usage resource with owner ref" + errAddDetailsAnnotation = "cannot update usage resource with details annotation" + errAddInUseLabel = "cannot add in use use label to the used resource" + errRemoveInUseLabel = "cannot remove in use label from the used resource" + errAddFinalizer = "cannot add finalizer" + errRemoveFinalizer = "cannot remove finalizer" + errUpdateStatus = "cannot update status of usage" +) + +// Event reasons. +const ( + reasonResolveSelectors event.Reason = "ResolveSelectors" + reasonListUsages event.Reason = "ListUsages" + reasonGetUsed event.Reason = "GetUsedResource" + reasonGetUsing event.Reason = "GetUsingResource" + reasonDetailsToUsage event.Reason = "AddDetailsToUsage" + reasonOwnerRefToUsage event.Reason = "AddOwnerRefToUsage" + reasonAddInUseLabel event.Reason = "AddInUseLabel" + reasonRemoveInUseLabel event.Reason = "RemoveInUseLabel" + reasonAddFinalizer event.Reason = "AddFinalizer" + reasonRemoveFinalizer event.Reason = "RemoveFinalizer" + + reasonUsageConfigured event.Reason = "UsageConfigured" + reasonWaitUsing event.Reason = "WaitingUsingDeleted" +) + +type selectorResolver interface { + resolveSelectors(ctx context.Context, u *v1alpha1.Usage) error +} + +// Setup adds a controller that reconciles Usages by +// defining a composite resource and starting a controller to reconcile it. +func Setup(mgr ctrl.Manager, o apiextensionscontroller.Options) error { + name := "usage/" + strings.ToLower(v1alpha1.UsageGroupKind) + r := NewReconciler(mgr, + WithLogger(o.Logger.WithValues("controller", name)), + WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), + WithPollInterval(o.PollInterval)) + + return ctrl.NewControllerManagedBy(mgr). + Named(name). + For(&v1alpha1.Usage{}). + WithOptions(o.ForControllerRuntime()). + Complete(ratelimiter.NewReconciler(name, r, o.GlobalRateLimiter)) +} + +// ReconcilerOption is used to configure the Reconciler. +type ReconcilerOption func(*Reconciler) + +// WithLogger specifies how the Reconciler should log messages. +func WithLogger(log logging.Logger) ReconcilerOption { + return func(r *Reconciler) { + r.log = log + } +} + +// WithRecorder specifies how the Reconciler should record Kubernetes events. +func WithRecorder(er event.Recorder) ReconcilerOption { + return func(r *Reconciler) { + r.record = er + } +} + +// WithClientApplicator specifies how the Reconciler should interact with the +// Kubernetes API. +func WithClientApplicator(c xpresource.ClientApplicator) ReconcilerOption { + return func(r *Reconciler) { + r.client = c + } +} + +// WithFinalizer specifies how the Reconciler should add and remove +// finalizers to and from the managed resource. +func WithFinalizer(f xpresource.Finalizer) ReconcilerOption { + return func(r *Reconciler) { + r.usage.Finalizer = f + } +} + +// WithSelectorResolver specifies how the Reconciler should resolve any +// resource references it encounters while reconciling Usages. +func WithSelectorResolver(sr selectorResolver) ReconcilerOption { + return func(r *Reconciler) { + r.usage.selectorResolver = sr + } +} + +// WithPollInterval specifies how long the Reconciler should wait before queueing +// a new reconciliation after a successful reconcile. The Reconciler requeues +// after a specified duration when it is not actively waiting for an external +// operation, but wishes to check whether resources it does not have a watch on +// (i.e. used/using resources) need to be reconciled. +func WithPollInterval(after time.Duration) ReconcilerOption { + return func(r *Reconciler) { + r.pollInterval = after + } +} + +type usageResource struct { + xpresource.Finalizer + selectorResolver +} + +// NewReconciler returns a Reconciler of Usages. +func NewReconciler(mgr manager.Manager, opts ...ReconcilerOption) *Reconciler { + kube := unstructured.NewClient(mgr.GetClient()) + + r := &Reconciler{ + client: xpresource.ClientApplicator{ + Client: kube, + Applicator: xpresource.NewAPIUpdatingApplicator(kube), + }, + + usage: usageResource{ + Finalizer: xpresource.NewAPIFinalizer(kube, finalizer), + selectorResolver: newAPISelectorResolver(kube), + }, + + log: logging.NewNopLogger(), + record: event.NewNopRecorder(), + } + + for _, f := range opts { + f(r) + } + return r +} + +// A Reconciler reconciles Usages. +type Reconciler struct { + client xpresource.ClientApplicator + + usage usageResource + + log logging.Logger + record event.Recorder + + pollInterval time.Duration +} + +// Reconcile a Usage resource by resolving its selectors, defining ownership +// relationship, adding a finalizer and handling proper deletion. +func (r *Reconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { //nolint:gocyclo // Reconcilers are typically complex. + log := r.log.WithValues("request", req) + ctx, cancel := context.WithTimeout(ctx, reconcileTimeout) + defer cancel() + + // Get the usageResource resource for this request. + u := &v1alpha1.Usage{} + if err := r.client.Get(ctx, req.NamespacedName, u); err != nil { + log.Debug(errGetUsage, "error", err) + return reconcile.Result{}, errors.Wrap(xpresource.IgnoreNotFound(err), errGetUsage) + } + + if err := r.usage.resolveSelectors(ctx, u); err != nil { + log.Debug(errResolveSelectors, "error", err) + err = errors.Wrap(err, errResolveSelectors) + r.record.Event(u, event.Warning(reasonResolveSelectors, err)) + return reconcile.Result{}, err + } + + r.record.Event(u, event.Normal(reasonResolveSelectors, "Selectors resolved, if any.")) + + of := u.Spec.Of + by := u.Spec.By + + // Identify used xp composed as an unstructured object. + used := composed.New(composed.FromReference(v1.ObjectReference{ + Kind: of.Kind, + Name: of.ResourceRef.Name, + APIVersion: of.APIVersion, + })) + + if meta.WasDeleted(u) { + if by != nil { + // Identify using resource as an unstructured object. + using := composed.New(composed.FromReference(v1.ObjectReference{ + Kind: by.Kind, + Name: by.ResourceRef.Name, + APIVersion: by.APIVersion, + })) + // Get the using resource + err := r.client.Get(ctx, client.ObjectKey{Name: by.ResourceRef.Name}, using) + if xpresource.IgnoreNotFound(err) != nil { + log.Debug(errGetUsing, "error", err) + err = errors.Wrap(xpresource.IgnoreNotFound(err), errGetUsing) + r.record.Event(u, event.Warning(reasonGetUsing, err)) + return reconcile.Result{}, err + } + + if err == nil { + // Using resource is still there, so we need to wait for it to be deleted. + msg := fmt.Sprintf("Waiting for the using resource (which is a %q named %q) to be deleted.", by.Kind, by.ResourceRef.Name) + log.Debug(msg) + r.record.Event(u, event.Normal(reasonWaitUsing, msg)) + // We are using a waitPollInterval which is shorter than the + // pollInterval to make sure we delete the usage as soon as + // possible after the using resource is deleted. This is + // to add minimal delay to the overall deletion process which is + // usually extended by backoff intervals. + return reconcile.Result{RequeueAfter: waitPollInterval}, nil + } + } + + // At this point using resource is either: + // - not defined + // - not found (e.g. deleted) + // So, we can proceed with the deletion of the usage. + + // Get the used resource + var err error + if err = r.client.Get(ctx, client.ObjectKey{Name: of.ResourceRef.Name}, used); xpresource.IgnoreNotFound(err) != nil { + log.Debug(errGetUsed, "error", err) + err = errors.Wrap(err, errGetUsed) + r.record.Event(u, event.Warning(reasonGetUsed, err)) + return reconcile.Result{}, err + } + + // Remove the in-use label from the used resource if no other usages + // exists. + if err == nil { + usageList := &v1alpha1.UsageList{} + if err = r.client.List(ctx, usageList, client.MatchingFields{usage.InUseIndexKey: usage.IndexValueForObject(used.GetUnstructured())}); err != nil { + log.Debug(errListUsages, "error", err) + err = errors.Wrap(err, errListUsages) + r.record.Event(u, event.Warning(reasonListUsages, err)) + return reconcile.Result{}, err + } + // There are no "other" usageResource's referencing the used resource, + // so we can remove the in-use label from the used resource + if len(usageList.Items) < 2 { + meta.RemoveLabels(used, inUseLabelKey) + if err = r.client.Update(ctx, used); err != nil { + log.Debug(errRemoveInUseLabel, "error", err) + err = errors.Wrap(err, errRemoveInUseLabel) + r.record.Event(u, event.Warning(reasonRemoveInUseLabel, err)) + return reconcile.Result{}, err + } + } + } + + // Remove the finalizer from the usage + if err = r.usage.RemoveFinalizer(ctx, u); err != nil { + log.Debug(errRemoveFinalizer, "error", err) + err = errors.Wrap(err, errRemoveFinalizer) + r.record.Event(u, event.Warning(reasonRemoveFinalizer, err)) + return reconcile.Result{}, err + } + + return reconcile.Result{}, nil + } + + // Add finalizer for Usage resource. + if err := r.usage.AddFinalizer(ctx, u); err != nil { + log.Debug(errAddFinalizer, "error", err) + err = errors.Wrap(err, errAddFinalizer) + r.record.Event(u, event.Warning(reasonAddFinalizer, err)) + return reconcile.Result{}, err + } + + d := detailsAnnotation(u) + if u.GetAnnotations()[detailsAnnotationKey] != d { + meta.AddAnnotations(u, map[string]string{ + detailsAnnotationKey: d, + }) + if err := r.client.Update(ctx, u); err != nil { + log.Debug(errAddDetailsAnnotation, "error", err) + err = errors.Wrap(err, errAddDetailsAnnotation) + r.record.Event(u, event.Warning(reasonDetailsToUsage, err)) + return reconcile.Result{}, err + } + } + + // Get the used resource + if err := r.client.Get(ctx, client.ObjectKey{Name: of.ResourceRef.Name}, used); err != nil { + log.Debug(errGetUsed, "error", err) + err = errors.Wrap(err, errGetUsed) + r.record.Event(u, event.Warning(reasonGetUsed, err)) + return reconcile.Result{}, err + } + + // Used resource should have in-use label. + if used.GetLabels()[inUseLabelKey] != "true" || !used.OwnedBy(u.GetUID()) { + // Note(turkenh): Composite controller will not remove this label with + // new reconciles since it uses a patching applicator to update the + // resource. + meta.AddLabels(used, map[string]string{inUseLabelKey: "true"}) + if err := r.client.Update(ctx, used); err != nil { + log.Debug(errAddInUseLabel, "error", err) + err = errors.Wrap(err, errAddInUseLabel) + r.record.Event(u, event.Warning(reasonAddInUseLabel, err)) + return reconcile.Result{}, err + } + } + + if by != nil { + // Identify using resource as an unstructured object. + using := composed.New(composed.FromReference(v1.ObjectReference{ + Kind: by.Kind, + Name: by.ResourceRef.Name, + APIVersion: by.APIVersion, + })) + + // Get the using resource + if err := r.client.Get(ctx, client.ObjectKey{Name: by.ResourceRef.Name}, using); err != nil { + log.Debug(errGetUsing, "error", err) + err = errors.Wrap(err, errGetUsing) + r.record.Event(u, event.Warning(reasonGetUsing, err)) + return reconcile.Result{}, err + } + + // usageResource should have a finalizer and be owned by the using resource. + if owners := u.GetOwnerReferences(); len(owners) == 0 || owners[0].UID != using.GetUID() { + meta.AddOwnerReference(u, meta.AsOwner( + meta.TypedReferenceTo(using, using.GetObjectKind().GroupVersionKind()), + )) + if err := r.client.Update(ctx, u); err != nil { + log.Debug(errAddOwnerToUsage, "error", err) + err = errors.Wrap(err, errAddOwnerToUsage) + r.record.Event(u, event.Warning(reasonOwnerRefToUsage, err)) + return reconcile.Result{}, err + } + } + } + + u.Status.SetConditions(xpv1.Available()) + r.record.Event(u, event.Normal(reasonUsageConfigured, "Usage configured successfully.")) + // We are only watching the Usage itself but not using or used resources. + // So, we need to reconcile the Usage periodically to check if the using + // or used resources are still there. + return reconcile.Result{RequeueAfter: r.pollInterval}, errors.Wrap(r.client.Status().Update(ctx, u), errUpdateStatus) +} + +func detailsAnnotation(u *v1alpha1.Usage) string { + if u.Spec.Reason != nil { + return *u.Spec.Reason + } + if u.Spec.By != nil { + return fmt.Sprintf("%s/%s uses %s/%s", u.Spec.By.Kind, u.Spec.By.ResourceRef.Name, u.Spec.Of.Kind, u.Spec.Of.ResourceRef.Name) + } + + return "undefined" +} + +// RespectOwnerRefs is an ApplyOption that ensures the existing owner references +// of the current Usage are respected. We need this option to be consumed in the +// composite controller since otherwise we lose the owner reference this +// controller puts on the Usage. +func RespectOwnerRefs() xpresource.ApplyOption { + return func(ctx context.Context, current, desired runtime.Object) error { + cu, ok := current.(*composed.Unstructured) + if !ok || cu.GetObjectKind().GroupVersionKind() != v1alpha1.UsageGroupVersionKind { + return nil + } + // This is a Usage resource, so we need to respect existing owner + // references in case it has any. + if len(cu.GetOwnerReferences()) > 0 { + desired.(metav1.Object).SetOwnerReferences(cu.GetOwnerReferences()) + } + return nil + } +} diff --git a/internal/controller/apiextensions/usage/reconciler_test.go b/internal/controller/apiextensions/usage/reconciler_test.go new file mode 100644 index 000000000..a9eee8c5a --- /dev/null +++ b/internal/controller/apiextensions/usage/reconciler_test.go @@ -0,0 +1,776 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package usage + +import ( + "context" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + corev1 "k8s.io/api/core/v1" + kerrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1" + "github.com/crossplane/crossplane-runtime/pkg/errors" + xpresource "github.com/crossplane/crossplane-runtime/pkg/resource" + "github.com/crossplane/crossplane-runtime/pkg/resource/fake" + "github.com/crossplane/crossplane-runtime/pkg/test" + + "github.com/crossplane/crossplane/apis/apiextensions/v1alpha1" + "github.com/crossplane/crossplane/internal/controller/apiextensions/usage/composed" + "github.com/crossplane/crossplane/internal/xcrd" +) + +type fakeSelectorResolver struct { + resourceSelectorFn func(ctx context.Context, u *v1alpha1.Usage) error +} + +func (f fakeSelectorResolver) resolveSelectors(ctx context.Context, u *v1alpha1.Usage) error { + return f.resourceSelectorFn(ctx, u) +} + +func TestReconcile(t *testing.T) { + now := metav1.Now() + reason := "protected" + type args struct { + mgr manager.Manager + opts []ReconcilerOption + } + type want struct { + r reconcile.Result + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "UsageNotFound": { + reason: "We should not return an error if the Usage was not found.", + args: args{ + mgr: &fake.Manager{}, + opts: []ReconcilerOption{ + WithClientApplicator(xpresource.ClientApplicator{ + Client: &test.MockClient{ + MockGet: test.NewMockGetFn(kerrors.NewNotFound(schema.GroupResource{}, "")), + }, + }), + }, + }, + want: want{ + r: reconcile.Result{}, + }, + }, + "CannotResolveSelectors": { + reason: "We should return an error if we cannot resolve selectors.", + args: args{ + mgr: &fake.Manager{}, + opts: []ReconcilerOption{ + WithClientApplicator(xpresource.ClientApplicator{ + Client: &test.MockClient{ + MockGet: test.NewMockGetFn(nil, func(obj client.Object) error { + o := obj.(*v1alpha1.Usage) + o.Spec.Of.ResourceSelector = &v1alpha1.ResourceSelector{MatchLabels: map[string]string{"foo": "bar"}} + return nil + }), + }, + }), + WithSelectorResolver(fakeSelectorResolver{ + resourceSelectorFn: func(ctx context.Context, u *v1alpha1.Usage) error { + return errBoom + }, + }), + }, + }, + want: want{ + err: errors.Wrap(errBoom, errResolveSelectors), + }, + }, + "CannotAddFinalizer": { + reason: "We should return an error if we cannot add finalizer.", + args: args{ + mgr: &fake.Manager{}, + opts: []ReconcilerOption{ + WithClientApplicator(xpresource.ClientApplicator{ + Client: &test.MockClient{ + MockGet: test.NewMockGetFn(nil, func(obj client.Object) error { + o := obj.(*v1alpha1.Usage) + o.Spec.Of.ResourceRef = &v1alpha1.ResourceRef{Name: "cool"} + return nil + }), + }, + }), + WithSelectorResolver(fakeSelectorResolver{ + resourceSelectorFn: func(ctx context.Context, u *v1alpha1.Usage) error { + return nil + }, + }), + WithFinalizer(xpresource.FinalizerFns{AddFinalizerFn: func(_ context.Context, _ xpresource.Object) error { + return errBoom + }}), + }, + }, + want: want{ + err: errors.Wrap(errBoom, errAddFinalizer), + }, + }, + "CannotAddDetailsAnnotation": { + reason: "We should return an error if we cannot add details annotation.", + args: args{ + mgr: &fake.Manager{}, + opts: []ReconcilerOption{ + WithClientApplicator(xpresource.ClientApplicator{ + Client: &test.MockClient{ + MockGet: test.NewMockGetFn(nil, func(obj client.Object) error { + o := obj.(*v1alpha1.Usage) + o.Spec.Of.ResourceRef = &v1alpha1.ResourceRef{Name: "cool"} + return nil + }), + MockUpdate: test.NewMockUpdateFn(errBoom), + }, + }), + WithSelectorResolver(fakeSelectorResolver{ + resourceSelectorFn: func(ctx context.Context, u *v1alpha1.Usage) error { + return nil + }, + }), + WithFinalizer(xpresource.FinalizerFns{AddFinalizerFn: func(_ context.Context, _ xpresource.Object) error { + return nil + }}), + }, + }, + want: want{ + err: errors.Wrap(errBoom, errAddDetailsAnnotation), + }, + }, + "CannotGetUsedResource": { + reason: "We should return an error if we cannot get used resource.", + args: args{ + mgr: &fake.Manager{}, + opts: []ReconcilerOption{ + WithClientApplicator(xpresource.ClientApplicator{ + Client: &test.MockClient{ + MockGet: test.NewMockGetFn(nil, func(obj client.Object) error { + switch o := obj.(type) { + case *v1alpha1.Usage: + o.Spec.Of.ResourceRef = &v1alpha1.ResourceRef{Name: "cool"} + case *composed.Unstructured: + return errBoom + } + return nil + }), + MockUpdate: test.NewMockUpdateFn(nil, func(obj client.Object) error { + return nil + }), + }, + }), + WithSelectorResolver(fakeSelectorResolver{ + resourceSelectorFn: func(ctx context.Context, u *v1alpha1.Usage) error { + return nil + }, + }), + WithFinalizer(xpresource.FinalizerFns{AddFinalizerFn: func(_ context.Context, _ xpresource.Object) error { + return nil + }}), + }, + }, + want: want{ + err: errors.Wrap(errBoom, errGetUsed), + }, + }, + "CannotUpdateUsedWithInUseLabel": { + reason: "We should return an error if we cannot update used resource with in-use label", + args: args{ + mgr: &fake.Manager{}, + opts: []ReconcilerOption{ + WithClientApplicator(xpresource.ClientApplicator{ + Client: &test.MockClient{ + MockGet: test.NewMockGetFn(nil, func(obj client.Object) error { + switch o := obj.(type) { + case *v1alpha1.Usage: + o.Spec.Of.ResourceRef = &v1alpha1.ResourceRef{Name: "cool"} + case *composed.Unstructured: + return nil + } + return nil + }), + MockUpdate: test.NewMockUpdateFn(nil, func(obj client.Object) error { + if _, ok := obj.(*composed.Unstructured); ok { + return errBoom + } + return nil + }), + }, + }), + WithSelectorResolver(fakeSelectorResolver{ + resourceSelectorFn: func(ctx context.Context, u *v1alpha1.Usage) error { + return nil + }, + }), + WithFinalizer(xpresource.FinalizerFns{AddFinalizerFn: func(_ context.Context, _ xpresource.Object) error { + return nil + }}), + }, + }, + want: want{ + err: errors.Wrap(errBoom, errAddInUseLabel), + }, + }, + "CannotGetUsingResource": { + reason: "We should return an error if we cannot get using resource.", + args: args{ + mgr: &fake.Manager{}, + opts: []ReconcilerOption{ + WithClientApplicator(xpresource.ClientApplicator{ + Client: &test.MockClient{ + MockGet: test.NewMockGetFn(nil, func(obj client.Object) error { + switch o := obj.(type) { + case *v1alpha1.Usage: + o.Spec.Of.ResourceRef = &v1alpha1.ResourceRef{Name: "used"} + o.Spec.By = &v1alpha1.Resource{ + ResourceRef: &v1alpha1.ResourceRef{Name: "using"}, + } + case *composed.Unstructured: + if o.GetName() == "using" { + return errBoom + } + } + return nil + }), + MockUpdate: test.NewMockUpdateFn(nil), + }, + }), + WithSelectorResolver(fakeSelectorResolver{ + resourceSelectorFn: func(ctx context.Context, u *v1alpha1.Usage) error { + return nil + }, + }), + WithFinalizer(xpresource.FinalizerFns{AddFinalizerFn: func(_ context.Context, _ xpresource.Object) error { + return nil + }}), + }, + }, + want: want{ + err: errors.Wrap(errBoom, errGetUsing), + }, + }, + "CannotAddOwnerRefToUsage": { + reason: "We should return an error if we cannot add owner reference to the Usage.", + args: args{ + mgr: &fake.Manager{}, + opts: []ReconcilerOption{ + WithClientApplicator(xpresource.ClientApplicator{ + Client: &test.MockClient{ + MockGet: test.NewMockGetFn(nil, func(obj client.Object) error { + if o, ok := obj.(*v1alpha1.Usage); ok { + o.Spec.Of.ResourceRef = &v1alpha1.ResourceRef{Name: "used"} + o.Spec.By = &v1alpha1.Resource{ + ResourceRef: &v1alpha1.ResourceRef{Name: "using"}, + } + return nil + } + if o, ok := obj.(*composed.Unstructured); ok { + if o.GetName() == "using" { + o.SetAPIVersion("v1") + o.SetKind("AnotherKind") + o.SetUID("some-uid") + } + return nil + } + return errors.New("unexpected object type") + }), + MockUpdate: test.NewMockUpdateFn(nil, func(obj client.Object) error { + if u, ok := obj.(*v1alpha1.Usage); ok { + if u.GetOwnerReferences() != nil { + return errBoom + } + } + return nil + }), + }, + }), + WithSelectorResolver(fakeSelectorResolver{ + resourceSelectorFn: func(ctx context.Context, u *v1alpha1.Usage) error { + return nil + }, + }), + WithFinalizer(xpresource.FinalizerFns{AddFinalizerFn: func(_ context.Context, _ xpresource.Object) error { + return nil + }}), + }, + }, + want: want{ + err: errors.Wrap(errBoom, errAddOwnerToUsage), + }, + }, + "SuccessWithUsingResource": { + reason: "We should return no error once we have successfully reconciled the usage resource.", + args: args{ + mgr: &fake.Manager{}, + opts: []ReconcilerOption{ + WithClientApplicator(xpresource.ClientApplicator{ + Client: &test.MockClient{ + MockGet: test.NewMockGetFn(nil, func(obj client.Object) error { + if o, ok := obj.(*v1alpha1.Usage); ok { + o.Spec.Of.ResourceRef = &v1alpha1.ResourceRef{Name: "used"} + o.Spec.By = &v1alpha1.Resource{ + ResourceRef: &v1alpha1.ResourceRef{Name: "using"}, + } + return nil + } + if o, ok := obj.(*composed.Unstructured); ok { + if o.GetName() == "using" { + o.SetAPIVersion("v1") + o.SetKind("AnotherKind") + o.SetUID("some-uid") + } + return nil + } + return errors.New("unexpected object type") + }), + MockUpdate: test.NewMockUpdateFn(nil, func(obj client.Object) error { + if o, ok := obj.(*v1alpha1.Usage); ok { + if o.GetOwnerReferences() != nil { + owner := o.GetOwnerReferences()[0] + if owner.APIVersion != "v1" || owner.Kind != "AnotherKind" || owner.UID != "some-uid" { + t.Errorf("expected owner reference to be set on usage properly") + } + } + } + return nil + }), + MockStatusUpdate: test.NewMockSubResourceUpdateFn(nil, func(obj client.Object) error { + o := obj.(*v1alpha1.Usage) + if o.Status.GetCondition(xpv1.TypeReady).Status != corev1.ConditionTrue { + t.Fatalf("expected ready condition to be true") + } + return nil + }), + }, + }), + WithSelectorResolver(fakeSelectorResolver{ + resourceSelectorFn: func(ctx context.Context, u *v1alpha1.Usage) error { + return nil + }, + }), + WithFinalizer(xpresource.FinalizerFns{AddFinalizerFn: func(_ context.Context, _ xpresource.Object) error { + return nil + }}), + }, + }, + want: want{ + r: reconcile.Result{}, + }, + }, + "SuccessNoUsingResource": { + reason: "We should return no error once we have successfully reconciled the usage resource.", + args: args{ + mgr: &fake.Manager{}, + opts: []ReconcilerOption{ + WithClientApplicator(xpresource.ClientApplicator{ + Client: &test.MockClient{ + MockGet: test.NewMockGetFn(nil, func(obj client.Object) error { + if o, ok := obj.(*v1alpha1.Usage); ok { + o.Spec.Of.ResourceRef = &v1alpha1.ResourceRef{Name: "cool"} + o.Spec.Reason = &reason + return nil + } + if _, ok := obj.(*composed.Unstructured); ok { + return nil + } + return errors.New("unexpected object type") + }), + MockUpdate: test.NewMockUpdateFn(nil, func(obj client.Object) error { + if o, ok := obj.(*composed.Unstructured); ok { + if o.GetLabels()[inUseLabelKey] != "true" { + t.Fatalf("expected %s label to be true", inUseLabelKey) + } + } + return nil + }), + MockStatusUpdate: test.NewMockSubResourceUpdateFn(nil, func(obj client.Object) error { + o := obj.(*v1alpha1.Usage) + if o.Status.GetCondition(xpv1.TypeReady).Status != corev1.ConditionTrue { + t.Fatalf("expected ready condition to be true") + } + return nil + }), + }, + }), + WithSelectorResolver(fakeSelectorResolver{ + resourceSelectorFn: func(ctx context.Context, u *v1alpha1.Usage) error { + return nil + }, + }), + WithFinalizer(xpresource.FinalizerFns{AddFinalizerFn: func(_ context.Context, _ xpresource.Object) error { + return nil + }}), + }, + }, + want: want{ + r: reconcile.Result{}, + }, + }, + "CannotRemoveFinalizerOnDelete": { + reason: "We should return an error if we cannot remove the finalizer on delete.", + args: args{ + mgr: &fake.Manager{}, + opts: []ReconcilerOption{ + WithClientApplicator(xpresource.ClientApplicator{ + Client: &test.MockClient{ + MockGet: test.NewMockGetFn(nil, func(obj client.Object) error { + if o, ok := obj.(*v1alpha1.Usage); ok { + o.SetDeletionTimestamp(&now) + o.Spec.Of.ResourceRef = &v1alpha1.ResourceRef{Name: "cool"} + return nil + } + if _, ok := obj.(*composed.Unstructured); ok { + return kerrors.NewNotFound(schema.GroupResource{}, "") + } + return errors.New("unexpected object type") + }), + }, + }), + WithSelectorResolver(fakeSelectorResolver{ + resourceSelectorFn: func(ctx context.Context, u *v1alpha1.Usage) error { + return nil + }, + }), + WithFinalizer(xpresource.FinalizerFns{RemoveFinalizerFn: func(_ context.Context, _ xpresource.Object) error { + return errBoom + }}), + }, + }, + want: want{ + err: errors.Wrap(errBoom, errRemoveFinalizer), + }, + }, + "CannotGetUsedOnDelete": { + reason: "We should return an error if we cannot get used resource on delete.", + args: args{ + mgr: &fake.Manager{}, + opts: []ReconcilerOption{ + WithClientApplicator(xpresource.ClientApplicator{ + Client: &test.MockClient{ + MockGet: test.NewMockGetFn(nil, func(obj client.Object) error { + if o, ok := obj.(*v1alpha1.Usage); ok { + o.SetDeletionTimestamp(&now) + o.Spec.Of.ResourceRef = &v1alpha1.ResourceRef{Name: "cool"} + return nil + } + if _, ok := obj.(*composed.Unstructured); ok { + return errBoom + } + return errors.New("unexpected object type") + }), + }, + }), + WithSelectorResolver(fakeSelectorResolver{ + resourceSelectorFn: func(ctx context.Context, u *v1alpha1.Usage) error { + return nil + }, + }), + WithFinalizer(xpresource.FinalizerFns{RemoveFinalizerFn: func(_ context.Context, _ xpresource.Object) error { + return nil + }}), + }, + }, + want: want{ + err: errors.Wrap(errBoom, errGetUsed), + }, + }, + "CannotGetUsingOnDelete": { + reason: "We should return an error if we cannot get using resource on delete.", + args: args{ + mgr: &fake.Manager{}, + opts: []ReconcilerOption{ + WithClientApplicator(xpresource.ClientApplicator{ + Client: &test.MockClient{ + MockGet: test.NewMockGetFn(nil, func(obj client.Object) error { + if o, ok := obj.(*v1alpha1.Usage); ok { + o.SetDeletionTimestamp(&now) + o.Spec.Of.ResourceRef = &v1alpha1.ResourceRef{Name: "used"} + o.Spec.By = &v1alpha1.Resource{ + APIVersion: "v1", + Kind: "AnotherKind", + ResourceRef: &v1alpha1.ResourceRef{Name: "using"}, + } + return nil + } + if o, ok := obj.(*composed.Unstructured); ok { + if o.GetName() == "used" { + o.SetLabels(map[string]string{inUseLabelKey: "true"}) + } + return errBoom + } + return errors.New("unexpected object type") + }), + }, + }), + WithSelectorResolver(fakeSelectorResolver{ + resourceSelectorFn: func(ctx context.Context, u *v1alpha1.Usage) error { + return nil + }, + }), + WithFinalizer(xpresource.FinalizerFns{RemoveFinalizerFn: func(_ context.Context, _ xpresource.Object) error { + return nil + }}), + }, + }, + want: want{ + err: errors.Wrap(errBoom, errGetUsing), + }, + }, + "CannotListUsagesOnDelete": { + reason: "We should return an error if we cannot list usages on delete.", + args: args{ + mgr: &fake.Manager{}, + opts: []ReconcilerOption{ + WithClientApplicator(xpresource.ClientApplicator{ + Client: &test.MockClient{ + MockGet: test.NewMockGetFn(nil, func(obj client.Object) error { + if o, ok := obj.(*v1alpha1.Usage); ok { + o.SetDeletionTimestamp(&now) + o.Spec.Of.ResourceRef = &v1alpha1.ResourceRef{Name: "cool"} + return nil + } + if o, ok := obj.(*composed.Unstructured); ok { + o.SetLabels(map[string]string{inUseLabelKey: "true"}) + return nil + } + return errors.New("unexpected object type") + }), + MockList: test.NewMockListFn(nil, func(obj client.ObjectList) error { + return errBoom + }), + }, + }), + WithSelectorResolver(fakeSelectorResolver{ + resourceSelectorFn: func(ctx context.Context, u *v1alpha1.Usage) error { + return nil + }, + }), + WithFinalizer(xpresource.FinalizerFns{RemoveFinalizerFn: func(_ context.Context, _ xpresource.Object) error { + return nil + }}), + }, + }, + want: want{ + err: errors.Wrap(errBoom, errListUsages), + }, + }, + "CannotRemoveLabelOnDelete": { + reason: "We should return an error if we cannot remove in use label on delete.", + args: args{ + mgr: &fake.Manager{}, + opts: []ReconcilerOption{ + WithClientApplicator(xpresource.ClientApplicator{ + Client: &test.MockClient{ + MockGet: test.NewMockGetFn(nil, func(obj client.Object) error { + if o, ok := obj.(*v1alpha1.Usage); ok { + o.SetDeletionTimestamp(&now) + o.Spec.Of.ResourceRef = &v1alpha1.ResourceRef{Name: "cool"} + return nil + } + if o, ok := obj.(*composed.Unstructured); ok { + o.SetLabels(map[string]string{inUseLabelKey: "true"}) + return nil + } + return errors.New("unexpected object type") + }), + MockList: test.NewMockListFn(nil, func(obj client.ObjectList) error { + return nil + }), + MockUpdate: test.NewMockUpdateFn(nil, func(obj client.Object) error { + return errBoom + }), + }, + }), + WithSelectorResolver(fakeSelectorResolver{ + resourceSelectorFn: func(ctx context.Context, u *v1alpha1.Usage) error { + return nil + }, + }), + WithFinalizer(xpresource.FinalizerFns{RemoveFinalizerFn: func(_ context.Context, _ xpresource.Object) error { + return nil + }}), + }, + }, + want: want{ + err: errors.Wrap(errBoom, errRemoveInUseLabel), + }, + }, + "SuccessfulDeleteUsedResourceGone": { + reason: "We should return no error once we have successfully deleted the usage resource.", + args: args{ + mgr: &fake.Manager{}, + opts: []ReconcilerOption{ + WithClientApplicator(xpresource.ClientApplicator{ + Client: &test.MockClient{ + MockGet: test.NewMockGetFn(nil, func(obj client.Object) error { + if o, ok := obj.(*v1alpha1.Usage); ok { + o.SetDeletionTimestamp(&now) + o.Spec.Of.ResourceRef = &v1alpha1.ResourceRef{Name: "cool"} + return nil + } + if _, ok := obj.(*composed.Unstructured); ok { + return kerrors.NewNotFound(schema.GroupResource{}, "") + } + return errors.New("unexpected object type") + }), + }, + }), + WithSelectorResolver(fakeSelectorResolver{ + resourceSelectorFn: func(ctx context.Context, u *v1alpha1.Usage) error { + return nil + }, + }), + WithFinalizer(xpresource.FinalizerFns{RemoveFinalizerFn: func(_ context.Context, _ xpresource.Object) error { + return nil + }}), + }, + }, + want: want{ + r: reconcile.Result{}, + }, + }, + "SuccessfulDeleteUsedLabelRemoved": { + reason: "We should return no error once we have successfully deleted the usage resource by removing in use label.", + args: args{ + mgr: &fake.Manager{}, + opts: []ReconcilerOption{ + WithClientApplicator(xpresource.ClientApplicator{ + Client: &test.MockClient{ + MockGet: test.NewMockGetFn(nil, func(obj client.Object) error { + if o, ok := obj.(*v1alpha1.Usage); ok { + o.SetDeletionTimestamp(&now) + o.Spec.Of.ResourceRef = &v1alpha1.ResourceRef{Name: "cool"} + return nil + } + if o, ok := obj.(*composed.Unstructured); ok { + o.SetLabels(map[string]string{inUseLabelKey: "true"}) + return nil + } + return errors.New("unexpected object type") + }), + MockList: test.NewMockListFn(nil, func(obj client.ObjectList) error { + return nil + }), + MockUpdate: test.NewMockUpdateFn(nil, func(obj client.Object) error { + if o, ok := obj.(*composed.Unstructured); ok { + if o.GetLabels()[inUseLabelKey] != "" { + t.Errorf("expected in use label to be removed") + } + return nil + } + return errors.New("unexpected object type") + }), + }, + }), + WithSelectorResolver(fakeSelectorResolver{ + resourceSelectorFn: func(ctx context.Context, u *v1alpha1.Usage) error { + return nil + }, + }), + WithFinalizer(xpresource.FinalizerFns{RemoveFinalizerFn: func(_ context.Context, _ xpresource.Object) error { + return nil + }}), + }, + }, + want: want{ + r: reconcile.Result{}, + }, + }, + "SuccessfulWaitWhenUsingStillThere": { + reason: "We should wait until the using resource is deleted.", + args: args{ + mgr: &fake.Manager{}, + opts: []ReconcilerOption{ + WithClientApplicator(xpresource.ClientApplicator{ + Client: &test.MockClient{ + MockGet: test.NewMockGetFn(nil, func(obj client.Object) error { + if o, ok := obj.(*v1alpha1.Usage); ok { + o.SetDeletionTimestamp(&now) + o.SetLabels(map[string]string{xcrd.LabelKeyNamePrefixForComposed: "some-composite"}) + o.Spec.Of.ResourceRef = &v1alpha1.ResourceRef{Name: "used"} + o.Spec.By = &v1alpha1.Resource{ + APIVersion: "v1", + Kind: "AnotherKind", + ResourceRef: &v1alpha1.ResourceRef{Name: "using"}, + } + return nil + } + if o, ok := obj.(*composed.Unstructured); ok { + if o.GetName() == "used" { + o.SetLabels(map[string]string{inUseLabelKey: "true"}) + } + o.SetLabels(map[string]string{ + xcrd.LabelKeyNamePrefixForComposed: "some-composite", + }) + return nil + } + return errors.New("unexpected object type") + }), + MockList: test.NewMockListFn(nil, func(obj client.ObjectList) error { + return nil + }), + MockUpdate: test.NewMockUpdateFn(nil, func(obj client.Object) error { + if o, ok := obj.(*composed.Unstructured); ok { + if o.GetLabels()[inUseLabelKey] != "" { + t.Errorf("expected in use label to be removed") + } + return nil + } + return errors.New("unexpected object type") + }), + }, + }), + WithSelectorResolver(fakeSelectorResolver{ + resourceSelectorFn: func(ctx context.Context, u *v1alpha1.Usage) error { + return nil + }, + }), + WithFinalizer(xpresource.FinalizerFns{RemoveFinalizerFn: func(_ context.Context, _ xpresource.Object) error { + return nil + }}), + }, + }, + want: want{ + r: reconcile.Result{RequeueAfter: 30 * time.Second}, + }, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + r := NewReconciler(tc.args.mgr, tc.args.opts...) + got, err := r.Reconcile(context.Background(), reconcile.Request{}) + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("\n%s\nr.Reconcile(...): -want error, +got error:\n%s", tc.reason, diff) + } + if diff := cmp.Diff(tc.want.r, got); diff != "" { + t.Errorf("\n%s\nr.Reconcile(...): -want result, +got:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/internal/controller/apiextensions/usage/selector.go b/internal/controller/apiextensions/usage/selector.go new file mode 100644 index 000000000..596967742 --- /dev/null +++ b/internal/controller/apiextensions/usage/selector.go @@ -0,0 +1,125 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package usage + +import ( + "context" + + v1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/crossplane/crossplane-runtime/pkg/errors" + "github.com/crossplane/crossplane-runtime/pkg/meta" + + "github.com/crossplane/crossplane/apis/apiextensions/v1alpha1" + "github.com/crossplane/crossplane/internal/controller/apiextensions/usage/composed" +) + +const ( + errUpdateAfterResolveSelector = "cannot update usage after resolving selector" + errResolveSelectorForUsingResource = "cannot resolve selector at \"spec.by.resourceSelector\"" + errResolveSelectorForUsedResource = "cannot resolve selector at \"spec.of.resourceSelector\"" + errNoSelectorToResolve = "no selector defined for resolving" + errListResourceMatchingLabels = "cannot list resources matching labels" + errFmtResourcesNotFound = "no %q found matching labels: %q" + errFmtResourcesNotFoundWithControllerRef = "no %q found matching labels: %q and with same controller reference" +) + +type apiSelectorResolver struct { + client client.Client +} + +func newAPISelectorResolver(c client.Client) *apiSelectorResolver { + return &apiSelectorResolver{client: c} +} + +func (r *apiSelectorResolver) resolveSelectors(ctx context.Context, u *v1alpha1.Usage) error { + of := u.Spec.Of + by := u.Spec.By + + if of.ResourceRef == nil || len(of.ResourceRef.Name) == 0 { + if err := r.resolveSelector(ctx, u, &of); err != nil { + return errors.Wrap(err, errResolveSelectorForUsedResource) + } + u.Spec.Of = of + if err := r.client.Update(ctx, u); err != nil { + return errors.Wrap(err, errUpdateAfterResolveSelector) + } + } + + if by == nil { + return nil + } + + if by.ResourceRef == nil || len(by.ResourceRef.Name) == 0 { + if err := r.resolveSelector(ctx, u, by); err != nil { + return errors.Wrap(err, errResolveSelectorForUsingResource) + } + u.Spec.By = by + if err := r.client.Update(ctx, u); err != nil { + return errors.Wrap(err, errUpdateAfterResolveSelector) + } + } + + return nil +} + +func (r *apiSelectorResolver) resolveSelector(ctx context.Context, u *v1alpha1.Usage, rs *v1alpha1.Resource) error { + l := composed.NewList(composed.FromReferenceToList(v1.ObjectReference{ + APIVersion: rs.APIVersion, + Kind: rs.Kind, + })) + + if rs.ResourceSelector == nil { + return errors.New(errNoSelectorToResolve) + } + if err := r.client.List(ctx, l, client.MatchingLabels(rs.ResourceSelector.MatchLabels)); err != nil { + return errors.Wrap(err, errListResourceMatchingLabels) + } + + if len(l.Items) == 0 { + return errors.Errorf(errFmtResourcesNotFound, rs.Kind, rs.ResourceSelector.MatchLabels) + } + + for i := range l.Items { + o := l.Items[i] + if controllersMustMatch(rs.ResourceSelector) && !meta.HaveSameController(&o, u) { + continue + } + rs.ResourceRef = &v1alpha1.ResourceRef{ + Name: o.GetName(), + } + break + } + + if rs.ResourceRef == nil { + return errors.Errorf(errFmtResourcesNotFoundWithControllerRef, rs.Kind, rs.ResourceSelector.MatchLabels) + } + + return nil +} + +// ControllersMustMatch returns true if the supplied Selector requires that a +// reference be to a resource whose controller reference matches the +// referencing resource. +func controllersMustMatch(s *v1alpha1.ResourceSelector) bool { + if s == nil { + return false + } + + return s.MatchControllerRef != nil && *s.MatchControllerRef +} diff --git a/internal/controller/apiextensions/usage/selector_test.go b/internal/controller/apiextensions/usage/selector_test.go new file mode 100644 index 000000000..160b26496 --- /dev/null +++ b/internal/controller/apiextensions/usage/selector_test.go @@ -0,0 +1,539 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package usage + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/crossplane/crossplane-runtime/pkg/errors" + "github.com/crossplane/crossplane-runtime/pkg/test" + + "github.com/crossplane/crossplane/apis/apiextensions/v1alpha1" + "github.com/crossplane/crossplane/internal/controller/apiextensions/usage/composed" +) + +var errBoom = errors.New("boom") + +func TestResolveSelectors(t *testing.T) { + valueTrue := true + type args struct { + client client.Client + u *v1alpha1.Usage + } + type want struct { + u *v1alpha1.Usage + err error + } + cases := map[string]struct { + reason string + args args + want want + }{ + "AlreadyResolved": { + reason: "If selectors resolved already, we should do nothing.", + args: args{ + u: &v1alpha1.Usage{ + Spec: v1alpha1.UsageSpec{ + Of: v1alpha1.Resource{ + APIVersion: "v1", + Kind: "SomeKind", + ResourceRef: &v1alpha1.ResourceRef{ + Name: "some", + }, + ResourceSelector: &v1alpha1.ResourceSelector{ + MatchLabels: map[string]string{ + "foo": "bar", + }, + }, + }, + By: &v1alpha1.Resource{ + APIVersion: "v1", + Kind: "AnotherKind", + ResourceRef: &v1alpha1.ResourceRef{ + Name: "another", + }, + ResourceSelector: &v1alpha1.ResourceSelector{ + MatchLabels: map[string]string{ + "baz": "qux", + }, + }, + }, + }, + }, + }, + want: want{ + u: &v1alpha1.Usage{ + Spec: v1alpha1.UsageSpec{ + Of: v1alpha1.Resource{ + APIVersion: "v1", + Kind: "SomeKind", + ResourceRef: &v1alpha1.ResourceRef{ + Name: "some", + }, + ResourceSelector: &v1alpha1.ResourceSelector{ + MatchLabels: map[string]string{ + "foo": "bar", + }, + }, + }, + By: &v1alpha1.Resource{ + APIVersion: "v1", + Kind: "AnotherKind", + ResourceRef: &v1alpha1.ResourceRef{ + Name: "another", + }, + ResourceSelector: &v1alpha1.ResourceSelector{ + MatchLabels: map[string]string{ + "baz": "qux", + }, + }, + }, + }, + }, + }, + }, + "AlreadyResolvedNoUsing": { + reason: "If selectors resolved already, we should do nothing.", + args: args{ + u: &v1alpha1.Usage{ + Spec: v1alpha1.UsageSpec{ + Of: v1alpha1.Resource{ + APIVersion: "v1", + Kind: "SomeKind", + ResourceRef: &v1alpha1.ResourceRef{ + Name: "some", + }, + ResourceSelector: &v1alpha1.ResourceSelector{ + MatchLabels: map[string]string{ + "foo": "bar", + }, + }, + }, + }, + }, + }, + want: want{ + u: &v1alpha1.Usage{ + Spec: v1alpha1.UsageSpec{ + Of: v1alpha1.Resource{ + APIVersion: "v1", + Kind: "SomeKind", + ResourceRef: &v1alpha1.ResourceRef{ + Name: "some", + }, + ResourceSelector: &v1alpha1.ResourceSelector{ + MatchLabels: map[string]string{ + "foo": "bar", + }, + }, + }, + }, + }, + }, + }, + "CannotResolveUsedListError": { + reason: "We should return error if we cannot list the used resources.", + args: args{ + client: &test.MockClient{ + MockList: func(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + return errBoom + }, + }, + u: &v1alpha1.Usage{ + Spec: v1alpha1.UsageSpec{ + Of: v1alpha1.Resource{ + APIVersion: "v1", + Kind: "SomeKind", + ResourceSelector: &v1alpha1.ResourceSelector{ + MatchLabels: map[string]string{ + "foo": "bar", + }, + }, + }, + }, + }, + }, + want: want{ + err: errors.Wrap(errors.Wrap(errBoom, errListResourceMatchingLabels), errResolveSelectorForUsedResource), + }, + }, + "CannotResolveUsingListError": { + reason: "We should return error if we cannot list the using resources.", + args: args{ + client: &test.MockClient{ + MockList: func(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + return errBoom + }, + }, + u: &v1alpha1.Usage{ + Spec: v1alpha1.UsageSpec{ + Of: v1alpha1.Resource{ + APIVersion: "v1", + Kind: "SomeKind", + ResourceRef: &v1alpha1.ResourceRef{ + Name: "some", + }, + }, + By: &v1alpha1.Resource{ + APIVersion: "v1", + Kind: "AnotherKind", + ResourceSelector: &v1alpha1.ResourceSelector{ + MatchLabels: map[string]string{ + "baz": "qux", + }, + }, + }, + }, + }, + }, + want: want{ + err: errors.Wrap(errors.Wrap(errBoom, errListResourceMatchingLabels), errResolveSelectorForUsingResource), + }, + }, + "CannotUpdateAfterResolvingUsed": { + reason: "We should return error if we cannot update the usage after resolving used resource.", + args: args{ + client: &test.MockClient{ + MockList: func(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + l := list.(*composed.UnstructuredList) + switch l.GetKind() { + case "SomeKindList": + l.Items = []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "SomeKind", + "metadata": map[string]interface{}{ + "name": "some", + }, + }, + }, + } + default: + t.Errorf("unexpected list kind: %s", l.GetKind()) + } + return nil + }, + MockUpdate: func(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error { + return errBoom + }, + }, + u: &v1alpha1.Usage{ + Spec: v1alpha1.UsageSpec{ + Of: v1alpha1.Resource{ + APIVersion: "v1", + Kind: "SomeKind", + ResourceSelector: &v1alpha1.ResourceSelector{ + MatchLabels: map[string]string{ + "foo": "bar", + }, + }, + }, + }, + }, + }, + want: want{ + err: errors.Wrap(errBoom, errUpdateAfterResolveSelector), + }, + }, + "CannotUpdateAfterResolvingUsing": { + reason: "We should return error if we cannot update the usage after resolving using resource.", + args: args{ + client: &test.MockClient{ + MockList: func(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + l := list.(*composed.UnstructuredList) + switch l.GetKind() { + case "AnotherKindList": + l.Items = []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "AnotherKind", + "metadata": map[string]interface{}{ + "name": "another", + }, + }, + }, + } + default: + t.Errorf("unexpected list kind: %s", l.GetKind()) + } + return nil + }, + MockUpdate: func(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error { + return errBoom + }, + }, + u: &v1alpha1.Usage{ + Spec: v1alpha1.UsageSpec{ + Of: v1alpha1.Resource{ + APIVersion: "v1", + Kind: "SomeKind", + ResourceRef: &v1alpha1.ResourceRef{ + Name: "some", + }, + }, + By: &v1alpha1.Resource{ + APIVersion: "v1", + Kind: "AnotherKind", + ResourceSelector: &v1alpha1.ResourceSelector{ + MatchLabels: map[string]string{ + "baz": "qux", + }, + }, + }, + }, + }, + }, + want: want{ + err: errors.Wrap(errBoom, errUpdateAfterResolveSelector), + }, + }, + "CannotResolveNoMatchingResources": { + reason: "We should return error if there are no matching resources.", + args: args{ + client: &test.MockClient{ + MockList: func(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + return nil + }, + }, + u: &v1alpha1.Usage{ + Spec: v1alpha1.UsageSpec{ + Of: v1alpha1.Resource{ + APIVersion: "v1", + Kind: "SomeKind", + ResourceSelector: &v1alpha1.ResourceSelector{ + MatchLabels: map[string]string{ + "foo": "bar", + }, + }, + }, + }, + }, + }, + want: want{ + err: errors.Wrap(errors.Errorf(errFmtResourcesNotFound, "SomeKind", map[string]string{"foo": "bar"}), errResolveSelectorForUsedResource), + }, + }, + + "CannotResolveNoMatchingResourcesWithControllerRef": { + reason: "We should return error if there are no matching resources with controller ref.", + args: args{ + client: &test.MockClient{ + MockList: func(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + l := list.(*composed.UnstructuredList) + switch l.GetKind() { + case "SomeKindList": + l.Items = []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "SomeKind", + "metadata": map[string]interface{}{ + "name": "some", + }, + }, + }, + } + default: + t.Errorf("unexpected list kind: %s", l.GetKind()) + } + return nil + }, + MockUpdate: func(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error { + return nil + }, + }, + u: &v1alpha1.Usage{ + Spec: v1alpha1.UsageSpec{ + Of: v1alpha1.Resource{ + APIVersion: "v1", + Kind: "SomeKind", + ResourceSelector: &v1alpha1.ResourceSelector{ + MatchLabels: map[string]string{ + "foo": "bar", + }, + MatchControllerRef: &valueTrue, + }, + }, + }, + }, + }, + want: want{ + err: errors.Wrap(errors.Errorf(errFmtResourcesNotFoundWithControllerRef, "SomeKind", map[string]string{"foo": "bar"}), errResolveSelectorForUsedResource), + }, + }, + "BothSelectorsResolved": { + reason: "If selectors defined for both \"of\" and \"by\", both should be resolved.", + args: args{ + client: &test.MockClient{ + MockList: func(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + l := list.(*composed.UnstructuredList) + if v := l.GroupVersionKind().Version; v != "v1" { + t.Errorf("unexpected list version: %s", v) + } + switch l.GetKind() { + case "SomeKindList": + if len(opts) != 1 && opts[0].(client.MatchingLabels)["foo"] != "bar" { + t.Errorf("unexpected list options: %v", opts) + } + l.Items = []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "SomeKind", + "metadata": map[string]interface{}{ + "name": "some", + "ownerReferences": []interface{}{ + map[string]interface{}{ + "apiVersion": "v1", + "kind": "OwnerKind", + "name": "owner", + "controller": true, + "uid": "some-uid", + }, + }, + }, + }, + }, + } + case "AnotherKindList": + if len(opts) != 1 && opts[0].(client.MatchingLabels)["baz"] != "qux" { + t.Errorf("unexpected list options: %v", opts) + } + l.Items = []unstructured.Unstructured{ + { + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "AnotherKind", + "metadata": map[string]interface{}{ + "name": "another", + }, + }, + }, + } + default: + t.Errorf("unexpected list kind: %s", l.GetKind()) + } + return nil + }, + MockUpdate: func(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error { + return nil + }, + }, + u: &v1alpha1.Usage{ + ObjectMeta: metav1.ObjectMeta{ + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "v1", + Kind: "OwnerKind", + Name: "owner", + Controller: &valueTrue, + UID: "some-uid", + }, + }, + }, + Spec: v1alpha1.UsageSpec{ + Of: v1alpha1.Resource{ + APIVersion: "v1", + Kind: "SomeKind", + ResourceSelector: &v1alpha1.ResourceSelector{ + MatchLabels: map[string]string{ + "foo": "bar", + }, + MatchControllerRef: &valueTrue, + }, + }, + By: &v1alpha1.Resource{ + APIVersion: "v1", + Kind: "AnotherKind", + ResourceSelector: &v1alpha1.ResourceSelector{ + MatchLabels: map[string]string{ + "baz": "qux", + }, + }, + }, + }, + Status: v1alpha1.UsageStatus{}, + }, + }, + want: want{ + u: &v1alpha1.Usage{ + ObjectMeta: metav1.ObjectMeta{ + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "v1", + Kind: "OwnerKind", + Name: "owner", + Controller: &valueTrue, + UID: "some-uid", + }, + }, + }, + Spec: v1alpha1.UsageSpec{ + Of: v1alpha1.Resource{ + APIVersion: "v1", + Kind: "SomeKind", + ResourceRef: &v1alpha1.ResourceRef{ + Name: "some", + }, + ResourceSelector: &v1alpha1.ResourceSelector{ + MatchLabels: map[string]string{ + "foo": "bar", + }, + MatchControllerRef: &valueTrue, + }, + }, + By: &v1alpha1.Resource{ + APIVersion: "v1", + Kind: "AnotherKind", + ResourceRef: &v1alpha1.ResourceRef{ + Name: "another", + }, + ResourceSelector: &v1alpha1.ResourceSelector{ + MatchLabels: map[string]string{ + "baz": "qux", + }, + }, + }, + }, + }, + }, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + r := newAPISelectorResolver(tc.args.client) + err := r.resolveSelectors(context.Background(), tc.args.u) + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("%s\nr.resolveSelectors(...): -want error, +got error:\n%s", tc.reason, diff) + } + if err != nil { + return + } + if diff := cmp.Diff(tc.want.u, tc.args.u); diff != "" { + t.Errorf("%s\nr.resolveSelectors(...): -want usage, +got error:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/internal/features/features.go b/internal/features/features.go index 3d9f03a27..608517962 100644 --- a/internal/features/features.go +++ b/internal/features/features.go @@ -41,6 +41,11 @@ const ( // https://github.com/crossplane/crossplane/blob/f32496bed53a393c8239376fd8266ddf2ef84d61/design/design-doc-composition-validating-webhook.md EnableAlphaCompositionWebhookSchemaValidation feature.Flag = "EnableAlphaCompositionWebhookSchemaValidation" + // EnableAlphaUsages enables alpha support for deletion ordering and + // protection with Usage resource. See the below design for more details. + // https://github.com/crossplane/crossplane/blob/19ea23e7c1fc16b20581755540f9f45afdf89338/design/one-pager-generic-usage-type.md + EnableAlphaUsages feature.Flag = "EnableAlphaUsages" + // EnableProviderIdentity enables alpha support for Provider identity. This // feature is only available when running on Upbound. EnableProviderIdentity feature.Flag = "EnableProviderIdentity" diff --git a/internal/initializer/webhook_configurations.go b/internal/initializer/webhook_configurations.go index ad69aa3fc..bb653e588 100644 --- a/internal/initializer/webhook_configurations.go +++ b/internal/initializer/webhook_configurations.go @@ -111,8 +111,14 @@ func (c *WebhookConfigurations) Run(ctx context.Context, kube client.Client) err conf.Webhooks[i].ClientConfig.Service.Namespace = c.ServiceReference.Namespace conf.Webhooks[i].ClientConfig.Service.Port = c.ServiceReference.Port } - // See https://github.com/kubernetes-sigs/controller-tools/issues/658 - conf.SetName("crossplane") + // Note(turkenh): We have validating webhook configurations other + // than the ones defined with kubebuilder/controller-tools, and we + // name them as we want. So, we need to apply workaround for the + // linked issue below only for the one generated by controller-tools. + if conf.GetName() == "validating-webhook-configuration" { + // See https://github.com/kubernetes-sigs/controller-tools/issues/658 + conf.SetName("crossplane") + } case *admv1.MutatingWebhookConfiguration: for i := range conf.Webhooks { conf.Webhooks[i].ClientConfig.CABundle = caBundle diff --git a/internal/usage/handler.go b/internal/usage/handler.go new file mode 100644 index 000000000..9a17ebc0d --- /dev/null +++ b/internal/usage/handler.go @@ -0,0 +1,162 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package usage contains the Handler for the usage webhook. +package usage + +import ( + "context" + "fmt" + "net/http" + + admissionv1 "k8s.io/api/admission/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + "github.com/crossplane/crossplane-runtime/pkg/controller" + "github.com/crossplane/crossplane-runtime/pkg/errors" + "github.com/crossplane/crossplane-runtime/pkg/logging" + xpunstructured "github.com/crossplane/crossplane-runtime/pkg/resource/unstructured" + + "github.com/crossplane/crossplane/apis/apiextensions/v1alpha1" +) + +const ( + // InUseIndexKey used to index CRDs by "Kind" and "group", to be used when + // indexing and retrieving needed CRDs + InUseIndexKey = "inuse.apiversion.kind.name" + + // Error strings. + errFmtUnexpectedOp = "unexpected operation %q, expected \"DELETE\"" +) + +// IndexValueForObject returns the index value for the given object. +func IndexValueForObject(u *unstructured.Unstructured) string { + return indexValue(u.GetAPIVersion(), u.GetKind(), u.GetName()) +} + +func indexValue(apiVersion, kind, name string) string { + return fmt.Sprintf("%s.%s.%s", apiVersion, kind, name) +} + +// SetupWebhookWithManager sets up the webhook with the manager. +func SetupWebhookWithManager(mgr ctrl.Manager, options controller.Options) error { + indexer := mgr.GetFieldIndexer() + if err := indexer.IndexField(context.Background(), &v1alpha1.Usage{}, InUseIndexKey, func(obj client.Object) []string { + u := obj.(*v1alpha1.Usage) + if u.Spec.Of.ResourceRef == nil || len(u.Spec.Of.ResourceRef.Name) == 0 { + return []string{} + } + return []string{indexValue(u.Spec.Of.APIVersion, u.Spec.Of.Kind, u.Spec.Of.ResourceRef.Name)} + }); err != nil { + return err + } + + mgr.GetWebhookServer().Register("/validate-no-usages", + &webhook.Admission{Handler: NewHandler( + xpunstructured.NewClient(mgr.GetClient()), + WithLogger(options.Logger.WithValues("webhook", "no-usages")), + )}) + return nil +} + +// Handler implements the admission Handler for Composition. +type Handler struct { + reader client.Reader + log logging.Logger +} + +// HandlerOption is used to configure the Handler. +type HandlerOption func(*Handler) + +// WithLogger configures the logger for the Handler. +func WithLogger(l logging.Logger) HandlerOption { + return func(h *Handler) { + h.log = l + } +} + +// NewHandler returns a new Handler. +func NewHandler(reader client.Reader, opts ...HandlerOption) *Handler { + h := &Handler{ + reader: reader, + log: logging.NewNopLogger(), + } + + for _, opt := range opts { + opt(h) + } + + return h +} + +// Handle handles the admission request, validating there is no usage for the +// resource being deleted. +func (h *Handler) Handle(ctx context.Context, request admission.Request) admission.Response { + switch request.Operation { + case admissionv1.Create, admissionv1.Update, admissionv1.Connect: + return admission.Errored(http.StatusBadRequest, errors.Errorf(errFmtUnexpectedOp, request.Operation)) + case admissionv1.Delete: + u := &unstructured.Unstructured{} + if err := u.UnmarshalJSON(request.OldObject.Raw); err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + return h.validateNoUsages(ctx, u) + default: + return admission.Errored(http.StatusBadRequest, errors.Errorf(errFmtUnexpectedOp, request.Operation)) + } +} + +func (h *Handler) validateNoUsages(ctx context.Context, u *unstructured.Unstructured) admission.Response { + h.log.Debug("Validating no usages", "apiVersion", u.GetAPIVersion(), "kind", u.GetKind(), "name", u.GetName()) + usageList := &v1alpha1.UsageList{} + if err := h.reader.List(ctx, usageList, client.MatchingFields{InUseIndexKey: IndexValueForObject(u)}); err != nil { + h.log.Debug("Error when getting Usages", "apiVersion", u.GetAPIVersion(), "kind", u.GetKind(), "name", u.GetName(), "err", err) + return admission.Errored(http.StatusInternalServerError, err) + } + if len(usageList.Items) > 0 { + msg := inUseMessage(usageList) + h.log.Debug("Usage found, deletion not allowed", "apiVersion", u.GetAPIVersion(), "kind", u.GetKind(), "name", u.GetName(), "msg", msg) + return admission.Response{ + AdmissionResponse: admissionv1.AdmissionResponse{ + Allowed: false, + Result: &metav1.Status{ + Code: int32(http.StatusConflict), + Reason: metav1.StatusReason(msg), + }, + }, + } + } + h.log.Debug("No usage found, deletion allowed", "apiVersion", u.GetAPIVersion(), "kind", u.GetKind(), "name", u.GetName()) + return admission.Allowed("") +} + +func inUseMessage(usages *v1alpha1.UsageList) string { + first := usages.Items[0] + if first.Spec.By != nil { + return fmt.Sprintf("This resource is in-use by %d Usage(s), including the Usage %q by resource %s/%s.", len(usages.Items), first.Name, first.Spec.By.Kind, first.Spec.By.ResourceRef.Name) + } + if first.Spec.Reason != nil { + return fmt.Sprintf("This resource is in-use by %d Usage(s), including the Usage %q with reason: %q.", len(usages.Items), first.Name, *first.Spec.Reason) + } + // Either spec.by or spec.reason should be set, which we enforce with a CEL + // rule. This is just a fallback. + return fmt.Sprintf("This resource is in-use by %d Usage(s), including the Usage %q.", len(usages.Items), first.Name) +} diff --git a/internal/usage/handler_test.go b/internal/usage/handler_test.go new file mode 100644 index 000000000..0b41ba8e1 --- /dev/null +++ b/internal/usage/handler_test.go @@ -0,0 +1,344 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package usage + +import ( + "context" + "net/http" + "testing" + + "github.com/google/go-cmp/cmp" + admissionv1 "k8s.io/api/admission/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + "github.com/crossplane/crossplane-runtime/pkg/errors" + "github.com/crossplane/crossplane-runtime/pkg/logging" + "github.com/crossplane/crossplane-runtime/pkg/test" + + "github.com/crossplane/crossplane/apis/apiextensions/v1alpha1" +) + +var _ admission.Handler = &Handler{} + +var errBoom = errors.New("boom") + +func TestHandle(t *testing.T) { + protected := "This resource is protected!" + type args struct { + reader client.Reader + request admission.Request + } + type want struct { + resp admission.Response + } + cases := map[string]struct { + reason string + args args + want want + }{ + "UnexpectedCreate": { + reason: "We should return an error if the request is a create (not a delete).", + args: args{ + request: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Create, + }, + }, + }, + want: want{ + resp: admission.Errored(http.StatusBadRequest, errors.Errorf(errFmtUnexpectedOp, admissionv1.Create)), + }, + }, + "UnexpectedConnect": { + reason: "We should return an error if the request is a connect (not a delete).", + args: args{ + request: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Connect, + }, + }, + }, + want: want{ + resp: admission.Errored(http.StatusBadRequest, errors.Errorf(errFmtUnexpectedOp, admissionv1.Connect)), + }, + }, + "UnexpectedUpdate": { + reason: "We should return an error if the request is an update (not a delete).", + args: args{ + request: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Update, + }, + }, + }, + want: want{ + resp: admission.Errored(http.StatusBadRequest, errors.Errorf(errFmtUnexpectedOp, admissionv1.Update)), + }, + }, + "UnexpectedOperation": { + reason: "We should return an error if the request is unknown (not a delete).", + args: args{ + request: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Operation("unknown"), + }, + }, + }, + want: want{ + resp: admission.Errored(http.StatusBadRequest, errors.Errorf(errFmtUnexpectedOp, admissionv1.Operation("unknown"))), + }, + }, + "DeleteWithoutOldObj": { + reason: "We should not return an error if delete request does not have the old object.", + args: args{ + request: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Delete, + }, + }, + }, + want: want{ + resp: admission.Errored(http.StatusBadRequest, errors.New("unexpected end of JSON input")), + }, + }, + "DeleteAllowedNoUsages": { + reason: "We should allow a delete request if there is no usages for the given object.", + args: args{ + reader: &test.MockClient{ + MockList: func(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + return nil + }, + }, + request: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Delete, + OldObject: runtime.RawExtension{ + Raw: []byte(`{ + "apiVersion": "nop.crossplane.io/v1alpha1", + "kind": "NopResource", + "metadata": { + "name": "used-resource" + }}`), + }, + }, + }, + }, + want: want{ + resp: admission.Allowed(""), + }, + }, + "DeleteRejectedCannotList": { + reason: "We should reject a delete request if we cannot list usages.", + args: args{ + reader: &test.MockClient{ + MockList: func(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + return errBoom + }, + }, + request: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Delete, + OldObject: runtime.RawExtension{ + Raw: []byte(`{ + "apiVersion": "nop.crossplane.io/v1alpha1", + "kind": "NopResource", + "metadata": { + "name": "used-resource" + }}`), + }, + }, + }, + }, + want: want{ + resp: admission.Errored(http.StatusInternalServerError, errBoom), + }, + }, + "DeleteBlockedWithUsageBy": { + reason: "We should reject a delete request if there are usages for the given object with \"by\" defined.", + args: args{ + reader: &test.MockClient{ + MockList: func(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + l := list.(*v1alpha1.UsageList) + l.Items = []v1alpha1.Usage{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "used-by-some-resource", + }, + Spec: v1alpha1.UsageSpec{ + Of: v1alpha1.Resource{ + APIVersion: "nop.crossplane.io/v1alpha1", + Kind: "NopResource", + ResourceRef: &v1alpha1.ResourceRef{ + Name: "used-resource", + }, + }, + By: &v1alpha1.Resource{ + APIVersion: "nop.crossplane.io/v1alpha1", + Kind: "NopResource", + ResourceRef: &v1alpha1.ResourceRef{ + Name: "using-resource", + }, + }, + }, + }, + } + return nil + }, + }, + request: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Delete, + OldObject: runtime.RawExtension{ + Raw: []byte(`{ + "apiVersion": "nop.crossplane.io/v1alpha1", + "kind": "NopResource", + "metadata": { + "name": "used-resource" + }}`), + }, + }, + }, + }, + want: want{ + resp: admission.Response{ + AdmissionResponse: admissionv1.AdmissionResponse{ + Allowed: false, + Result: &metav1.Status{ + Code: int32(http.StatusConflict), + Reason: metav1.StatusReason("This resource is in-use by 1 Usage(s), including the Usage \"used-by-some-resource\" by resource NopResource/using-resource."), + }, + }, + }, + }, + }, + "DeleteBlockedWithUsageReason": { + reason: "We should reject a delete request if there are usages for the given object with \"reason\" defined.", + args: args{ + reader: &test.MockClient{ + MockList: func(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + l := list.(*v1alpha1.UsageList) + l.Items = []v1alpha1.Usage{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "used-by-some-resource", + }, + Spec: v1alpha1.UsageSpec{ + Of: v1alpha1.Resource{ + APIVersion: "nop.crossplane.io/v1alpha1", + Kind: "NopResource", + ResourceRef: &v1alpha1.ResourceRef{ + Name: "used-resource", + }, + }, + Reason: &protected, + }, + }, + } + return nil + }, + }, + request: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Delete, + OldObject: runtime.RawExtension{ + Raw: []byte(`{ + "apiVersion": "nop.crossplane.io/v1alpha1", + "kind": "NopResource", + "metadata": { + "name": "used-resource" + }}`), + }, + }, + }, + }, + want: want{ + resp: admission.Response{ + AdmissionResponse: admissionv1.AdmissionResponse{ + Allowed: false, + Result: &metav1.Status{ + Code: int32(http.StatusConflict), + Reason: metav1.StatusReason("This resource is in-use by 1 Usage(s), including the Usage \"used-by-some-resource\" with reason: \"This resource is protected!\"."), + }, + }, + }, + }, + }, + "DeleteBlockedWithUsageNone": { + reason: "We should reject a delete request if there are usages for the given object without \"reason\" or \"by\" defined.", + args: args{ + reader: &test.MockClient{ + MockList: func(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + l := list.(*v1alpha1.UsageList) + l.Items = []v1alpha1.Usage{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "used-by-some-resource", + }, + Spec: v1alpha1.UsageSpec{ + Of: v1alpha1.Resource{ + APIVersion: "nop.crossplane.io/v1alpha1", + Kind: "NopResource", + ResourceRef: &v1alpha1.ResourceRef{ + Name: "used-resource", + }, + }, + }, + }, + } + return nil + }, + }, + request: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Delete, + OldObject: runtime.RawExtension{ + Raw: []byte(`{ + "apiVersion": "nop.crossplane.io/v1alpha1", + "kind": "NopResource", + "metadata": { + "name": "used-resource" + }}`), + }, + }, + }, + }, + want: want{ + resp: admission.Response{ + AdmissionResponse: admissionv1.AdmissionResponse{ + Allowed: false, + Result: &metav1.Status{ + Code: int32(http.StatusConflict), + Reason: metav1.StatusReason("This resource is in-use by 1 Usage(s), including the Usage \"used-by-some-resource\"."), + }, + }, + }, + }, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + h := NewHandler(tc.args.reader, WithLogger(logging.NewNopLogger())) + got := h.Handle(context.Background(), tc.args.request) + if diff := cmp.Diff(tc.want.resp, got); diff != "" { + t.Errorf("%s\nHandle(...): -want response, +got:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/pkg/validation/apiextensions/v1/composition/patches.go b/pkg/validation/apiextensions/v1/composition/patches.go index c85965110..b09ce5e53 100644 --- a/pkg/validation/apiextensions/v1/composition/patches.go +++ b/pkg/validation/apiextensions/v1/composition/patches.go @@ -322,10 +322,10 @@ func validateIOTypesWithTransforms(transforms []v1.Transform, fromType, toType x return field.Invalid(field.NewPath("transforms"), transforms, fmt.Sprintf("the provided transforms do not output a type compatible with the toFieldPath according to the schema: %s != %s", fromType, toType)) } -func validateTransformsChainIOTypes(transforms []v1.Transform, fromType xpschema.KnownJSONType) (outputType v1.TransformIOType, fErr *field.Error) { +func validateTransformsChainIOTypes(transforms []v1.Transform, fromType xpschema.KnownJSONType) (v1.TransformIOType, *field.Error) { inputType, err := xpschema.FromKnownJSONType(fromType) if err != nil && fromType != "" { - return "", field.InternalError(field.NewPath("transforms"), fErr) + return "", field.InternalError(field.NewPath("transforms"), err) } for i, transform := range transforms { transform := transform @@ -479,8 +479,29 @@ func IsValidInputForTransform(t *v1.Transform, fromType v1.TransformIOType) erro return errors.Errorf("match transform can only be used with string input types, got %s", fromType) } case v1.TransformTypeString: - if fromType != v1.TransformIOTypeString { - return errors.Errorf("string transform can only be used with string input types, got %s", fromType) + switch t.String.Type { + case v1.StringTransformTypeRegexp, v1.StringTransformTypeTrimSuffix, v1.StringTransformTypeTrimPrefix: + if fromType != v1.TransformIOTypeString { + return errors.Errorf("string transform can only be used with string input types, got %s", fromType) + } + case v1.StringTransformTypeFormat: + // any input type is valid + case v1.StringTransformTypeConvert: + if t.String.Convert == nil { + return errors.Errorf("string transform convert type is required for convert transform") + } + switch *t.String.Convert { + case v1.StringConversionTypeToLower, v1.StringConversionTypeToUpper, v1.StringConversionTypeFromBase64, v1.StringConversionTypeToBase64: + if fromType != v1.TransformIOTypeString { + return errors.Errorf("string transform can only be used with string input types, got %s", fromType) + } + case v1.StringConversionTypeToJSON, v1.StringConversionTypeToSHA1, v1.StringConversionTypeToSHA256, v1.StringConversionTypeToSHA512: + // any input type is valid + default: + return errors.Errorf("unknown string conversion type %s", *t.String.Convert) + } + default: + return errors.Errorf("unknown string transform type %s", t.String.Type) } case v1.TransformTypeConvert: if _, err := composite.GetConversionFunc(t.Convert, fromType); err != nil { diff --git a/pkg/validation/apiextensions/v1/composition/patches_test.go b/pkg/validation/apiextensions/v1/composition/patches_test.go index 114c5f4dc..e49c2bae6 100644 --- a/pkg/validation/apiextensions/v1/composition/patches_test.go +++ b/pkg/validation/apiextensions/v1/composition/patches_test.go @@ -313,6 +313,23 @@ func TestValidateTransforms(t *testing.T) { toType: "string", }, }, + "AcceptObjectInputTypeToJsonStringTransform": { + reason: "Should accept object input type with json string transform", + want: want{err: nil}, + args: args{ + transforms: []v1.Transform{ + { + Type: v1.TransformTypeString, + String: &v1.StringTransform{ + Type: v1.StringTransformTypeConvert, + Convert: &[]v1.StringConversionType{v1.StringConversionTypeToJSON}[0], + }, + }, + }, + fromType: "object", + toType: "string", + }, + }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { @@ -943,3 +960,70 @@ func TestComposedTemplateGetBaseObject(t *testing.T) { }) } } + +func TestIsValidInputForTransform(t *testing.T) { + type args struct { + t *v1.Transform + fromType v1.TransformIOType + } + type want struct { + err bool + } + tests := map[string]struct { + reason string + args args + want want + }{ + "ValidStringTransformInputString": { + reason: "Valid String transformType should not return an error with input string", + args: args{ + fromType: v1.TransformIOTypeString, + t: &v1.Transform{ + Type: v1.TransformTypeString, + String: &v1.StringTransform{ + Type: v1.StringTransformTypeConvert, + Convert: toPointer(v1.StringConversionTypeToUpper), + }, + }, + }, + }, + "ValidStringTransformInputObjectToJson": { + reason: "Valid String transformType should not return an error with input object if toJson", + args: args{ + fromType: v1.TransformIOTypeObject, + t: &v1.Transform{ + Type: v1.TransformTypeString, + String: &v1.StringTransform{ + Type: v1.StringTransformTypeConvert, + Convert: toPointer(v1.StringConversionTypeToJSON), + }, + }, + }, + }, + "InValidStringTransformInputObjectToUpper": { + reason: "Valid String transformType should not return an error with input string", + args: args{ + fromType: v1.TransformIOTypeObject, + t: &v1.Transform{ + Type: v1.TransformTypeString, + String: &v1.StringTransform{ + Type: v1.StringTransformTypeConvert, + Convert: toPointer(v1.StringConversionTypeToUpper), + }, + }, + }, + want: want{ + err: true, + }, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + err := IsValidInputForTransform(tc.args.t, tc.args.fromType) + if tc.want.err && err == nil { + t.Errorf("\n%s\nIsValidInputForTransform(...): -want error, +got error: \n%s", tc.reason, err) + return + } + }) + } +} diff --git a/pkg/validation/internal/schema/schema.go b/pkg/validation/internal/schema/schema.go index cedaacf74..81ded85e1 100644 --- a/pkg/validation/internal/schema/schema.go +++ b/pkg/validation/internal/schema/schema.go @@ -66,6 +66,10 @@ func FromTransformIOType(c v1.TransformIOType) KnownJSONType { return KnownJSONTypeInteger case v1.TransformIOTypeFloat64: return KnownJSONTypeNumber + case v1.TransformIOTypeObject: + return KnownJSONTypeObject + case v1.TransformIOTypeArray: + return KnownJSONTypeArray } // should never happen return "" @@ -82,7 +86,11 @@ func FromKnownJSONType(t KnownJSONType) (v1.TransformIOType, error) { return v1.TransformIOTypeInt64, nil case KnownJSONTypeNumber: return v1.TransformIOTypeFloat64, nil - case KnownJSONTypeObject, KnownJSONTypeArray, KnownJSONTypeNull: + case KnownJSONTypeObject: + return v1.TransformIOTypeObject, nil + case KnownJSONTypeArray: + return v1.TransformIOTypeObject, nil + case KnownJSONTypeNull: return "", errors.Errorf(errFmtUnsupportedJSONType, t) default: return "", errors.Errorf(errFmtUnknownJSONType, t)