From 753d8849cb0f1874ba98a6d5f1f5077614fc0e00 Mon Sep 17 00:00:00 2001 From: Predrag Knezevic Date: Mon, 8 Apr 2024 10:59:36 +0200 Subject: [PATCH] Add `ObservedObjectCollection` API type (#217) * Add `ObservedObjectCollection` API type Objects in the collection are defined by: * GVK * optional namespace * label selector The objects are fetched using the specified provider config and for the matched objects the provider creates counterpart observe-only objects in the local cluster. The created objects are owned by the collection resource and reconciled as usual by the provider. They are labeled with a common label, so that they can be fetched easily. The label is discoverable by reading `.status.membershipLabel` field of `ObservedObjectCollection`. Signed-off-by: Predrag Knezevic --- apis/kubernetes.go | 2 + apis/observedobjectcollection/v1alpha1/doc.go | 22 + .../v1alpha1/register.go | 50 +++ .../v1alpha1/types.go | 126 ++++++ .../v1alpha1/zz_generated.deepcopy.go | 205 +++++++++ examples/collection/collection.yaml | 42 ++ go.mod | 2 +- internal/clients/client.go | 88 ++++ internal/controller/kubernetes.go | 4 + internal/controller/object/object.go | 102 +---- internal/controller/object/object_test.go | 394 +---------------- .../observedobjectcollection/reconciler.go | 286 +++++++++++++ .../reconciler_test.go | 402 ++++++++++++++++++ ...ossplane.io_observedobjectcollections.yaml | 257 +++++++++++ 14 files changed, 1509 insertions(+), 473 deletions(-) create mode 100644 apis/observedobjectcollection/v1alpha1/doc.go create mode 100644 apis/observedobjectcollection/v1alpha1/register.go create mode 100644 apis/observedobjectcollection/v1alpha1/types.go create mode 100644 apis/observedobjectcollection/v1alpha1/zz_generated.deepcopy.go create mode 100644 examples/collection/collection.yaml create mode 100644 internal/controller/observedobjectcollection/reconciler.go create mode 100644 internal/controller/observedobjectcollection/reconciler_test.go create mode 100644 package/crds/kubernetes.crossplane.io_observedobjectcollections.yaml diff --git a/apis/kubernetes.go b/apis/kubernetes.go index f49b7515..735ad61b 100644 --- a/apis/kubernetes.go +++ b/apis/kubernetes.go @@ -22,6 +22,7 @@ import ( objectv1alpha1 "github.com/crossplane-contrib/provider-kubernetes/apis/object/v1alpha1" objectv1alhpa2 "github.com/crossplane-contrib/provider-kubernetes/apis/object/v1alpha2" + observedobjectcollectionv1alpha1 "github.com/crossplane-contrib/provider-kubernetes/apis/observedobjectcollection/v1alpha1" templatev1alpha1 "github.com/crossplane-contrib/provider-kubernetes/apis/v1alpha1" ) @@ -31,6 +32,7 @@ func init() { templatev1alpha1.SchemeBuilder.AddToScheme, objectv1alpha1.SchemeBuilder.AddToScheme, objectv1alhpa2.SchemeBuilder.AddToScheme, + observedobjectcollectionv1alpha1.SchemeBuilder.AddToScheme, ) } diff --git a/apis/observedobjectcollection/v1alpha1/doc.go b/apis/observedobjectcollection/v1alpha1/doc.go new file mode 100644 index 00000000..b8e60b1d --- /dev/null +++ b/apis/observedobjectcollection/v1alpha1/doc.go @@ -0,0 +1,22 @@ +/* +Copyright 2024 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 contains the v1alpha1 group ObservedObjectCollection resources of the Kubernetes provider. +// +kubebuilder:ac:generate=true +// +kubebuilder:object:generate=true +// +groupName=kubernetes.crossplane.io +// +versionName=v1alpha1 +package v1alpha1 diff --git a/apis/observedobjectcollection/v1alpha1/register.go b/apis/observedobjectcollection/v1alpha1/register.go new file mode 100644 index 00000000..c438e3f9 --- /dev/null +++ b/apis/observedobjectcollection/v1alpha1/register.go @@ -0,0 +1,50 @@ +/* +Copyright 2024 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 ( + "reflect" + + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +// Package type metadata. +const ( + Group = "kubernetes.crossplane.io" + Version = "v1alpha1" +) + +var ( + // SchemeGroupVersion is group version used to register these objects + SchemeGroupVersion = schema.GroupVersion{Group: Group, Version: Version} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme + SchemeBuilder = &scheme.Builder{GroupVersion: SchemeGroupVersion} +) + +// ProviderConfig type metadata. +var ( + ObservedObjectCollectionKind = reflect.TypeOf(ObservedObjectCollection{}).Name() + ObservedObjectCollectionGroupKind = schema.GroupKind{Group: Group, Kind: ObservedObjectCollectionKind}.String() + ObservedObjectCollectionAPIVersion = ObservedObjectCollectionKind + "." + SchemeGroupVersion.String() + ObservedObjectCollectionGroupVersionKind = SchemeGroupVersion.WithKind(ObservedObjectCollectionKind) +) + +func init() { + SchemeBuilder.Register(&ObservedObjectCollection{}, &ObservedObjectCollectionList{}) +} diff --git a/apis/observedobjectcollection/v1alpha1/types.go b/apis/observedobjectcollection/v1alpha1/types.go new file mode 100644 index 00000000..3ac93c4a --- /dev/null +++ b/apis/observedobjectcollection/v1alpha1/types.go @@ -0,0 +1,126 @@ +/* +Copyright 2024 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 ( + "k8s.io/apimachinery/pkg/apis/meta/v1" + + v12 "github.com/crossplane/crossplane-runtime/apis/common/v1" +) + +// +kubebuilder:object:root=true + +// A ObservedObjectCollection is a provider Kubernetes API type +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="KIND",type="string",JSONPath=".spec.kind" +// +kubebuilder:printcolumn:name="APIVERSION",type="string",JSONPath=".spec.apiVersion",priority=1 +// +kubebuilder:printcolumn:name="PROVIDERCONFIG",type="string",JSONPath=".spec.providerConfigRef.name" +// +kubebuilder:printcolumn:name="SYNCED",type="string",JSONPath=".status.conditions[?(@.type=='Synced')].status" +// +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,managed,kubernetes} +// +kubebuilder:validation:XValidation:rule="size(self.metadata.name) < 64",message="metadata.name max length is 63" +type ObservedObjectCollection struct { + v1.TypeMeta `json:",inline"` + v1.ObjectMeta `json:"metadata,omitempty"` + Spec ObservedObjectCollectionSpec `json:"spec"` + Status ObservedObjectCollectionStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// ObservedObjectCollectionList contains a list of ObservedObjectCollection +type ObservedObjectCollectionList struct { + v1.TypeMeta `json:",inline"` + v1.ListMeta `json:"metadata,omitempty"` + Items []ObservedObjectCollection `json:"items"` +} + +// ObservedObjectCollectionSpec defines the desired state of ObservedObjectCollection +type ObservedObjectCollectionSpec struct { + + // ObserveObjects declares what criteria object need to fulfil + // to become a member of this collection + ObserveObjects ObserveObjectCriteria `json:"observeObjects"` + + // ProviderConfigReference specifies how the provider that will be used to + // create, observe, update, and delete this managed resource should be + // configured. + // +kubebuilder:default={"name": "default"} + ProviderConfigReference v12.Reference `json:"providerConfigRef,omitempty"` + + // Template when defined is used for creating Object instances + // +optional + Template *ObservedObjectTemplate `json:"objectTemplate,omitempty"` +} + +// ObserveObjectCriteria declares criteria for an object to be a part of collection +type ObserveObjectCriteria struct { + + // APIVersion of objects that should be matched by the selector + // +kubebuilder:validation:MinLength:=1 + APIVersion string `json:"apiVersion"` + + // Kind of objects that should be matched by the selector + // +kubebuilder:validation:MinLength:=1 + Kind string `json:"kind"` + + // Namespace where to look for objects. + // If omitted, search is performed across all namespaces. + // For cluster-scoped objects, omit it. + // +optional + Namespace string `json:"namespace,omitempty"` + + // Selector defines the criteria for including objects into the collection + Selector v1.LabelSelector `json:"selector"` +} + +// ObservedObjectTemplate represents template used when creating observe-only Objects matching the given selector +type ObservedObjectTemplate struct { + + // Objects metadata + Metadata ObservedObjectTemplateMetadata `json:"metadata,omitempty"` +} + +// ObservedObjectTemplateMetadata represents objects metadata +type ObservedObjectTemplateMetadata struct { + + // Labels of an object + Labels map[string]string `json:"labels,omitempty"` + + // Annotations of an object + Annotations map[string]string `json:"annotations,omitempty"` +} + +// ObservedObjectCollectionStatus represents the observed state of a ObservedObjectCollection +type ObservedObjectCollectionStatus struct { + v12.ResourceStatus `json:",inline"` + + // MembershipLabel is the label set on each member of this collection + // and can be used for fetching them. + // +optional + MembershipLabel map[string]string `json:"membershipLabel,omitempty"` +} + +// ObservedObjectReference represents a reference to Object with ObserveOnly management policy +type ObservedObjectReference struct { + + // Name of the observed object + // +kubebuilder:validation:MinLength:=1 + // +kubebuilder:validation:MaxLength:=253 + Name string `json:"name"` +} diff --git a/apis/observedobjectcollection/v1alpha1/zz_generated.deepcopy.go b/apis/observedobjectcollection/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 00000000..76572f75 --- /dev/null +++ b/apis/observedobjectcollection/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,205 @@ +//go:build !ignore_autogenerated + +/* +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. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ObserveObjectCriteria) DeepCopyInto(out *ObserveObjectCriteria) { + *out = *in + in.Selector.DeepCopyInto(&out.Selector) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ObserveObjectCriteria. +func (in *ObserveObjectCriteria) DeepCopy() *ObserveObjectCriteria { + if in == nil { + return nil + } + out := new(ObserveObjectCriteria) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ObservedObjectCollection) DeepCopyInto(out *ObservedObjectCollection) { + *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 ObservedObjectCollection. +func (in *ObservedObjectCollection) DeepCopy() *ObservedObjectCollection { + if in == nil { + return nil + } + out := new(ObservedObjectCollection) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ObservedObjectCollection) 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 *ObservedObjectCollectionList) DeepCopyInto(out *ObservedObjectCollectionList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ObservedObjectCollection, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ObservedObjectCollectionList. +func (in *ObservedObjectCollectionList) DeepCopy() *ObservedObjectCollectionList { + if in == nil { + return nil + } + out := new(ObservedObjectCollectionList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ObservedObjectCollectionList) 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 *ObservedObjectCollectionSpec) DeepCopyInto(out *ObservedObjectCollectionSpec) { + *out = *in + in.ObserveObjects.DeepCopyInto(&out.ObserveObjects) + in.ProviderConfigReference.DeepCopyInto(&out.ProviderConfigReference) + if in.Template != nil { + in, out := &in.Template, &out.Template + *out = new(ObservedObjectTemplate) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ObservedObjectCollectionSpec. +func (in *ObservedObjectCollectionSpec) DeepCopy() *ObservedObjectCollectionSpec { + if in == nil { + return nil + } + out := new(ObservedObjectCollectionSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ObservedObjectCollectionStatus) DeepCopyInto(out *ObservedObjectCollectionStatus) { + *out = *in + in.ResourceStatus.DeepCopyInto(&out.ResourceStatus) + if in.MembershipLabel != nil { + in, out := &in.MembershipLabel, &out.MembershipLabel + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ObservedObjectCollectionStatus. +func (in *ObservedObjectCollectionStatus) DeepCopy() *ObservedObjectCollectionStatus { + if in == nil { + return nil + } + out := new(ObservedObjectCollectionStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ObservedObjectReference) DeepCopyInto(out *ObservedObjectReference) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ObservedObjectReference. +func (in *ObservedObjectReference) DeepCopy() *ObservedObjectReference { + if in == nil { + return nil + } + out := new(ObservedObjectReference) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ObservedObjectTemplate) DeepCopyInto(out *ObservedObjectTemplate) { + *out = *in + in.Metadata.DeepCopyInto(&out.Metadata) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ObservedObjectTemplate. +func (in *ObservedObjectTemplate) DeepCopy() *ObservedObjectTemplate { + if in == nil { + return nil + } + out := new(ObservedObjectTemplate) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ObservedObjectTemplateMetadata) DeepCopyInto(out *ObservedObjectTemplateMetadata) { + *out = *in + if in.Labels != nil { + in, out := &in.Labels, &out.Labels + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Annotations != nil { + in, out := &in.Annotations, &out.Annotations + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ObservedObjectTemplateMetadata. +func (in *ObservedObjectTemplateMetadata) DeepCopy() *ObservedObjectTemplateMetadata { + if in == nil { + return nil + } + out := new(ObservedObjectTemplateMetadata) + in.DeepCopyInto(out) + return out +} diff --git a/examples/collection/collection.yaml b/examples/collection/collection.yaml new file mode 100644 index 00000000..17c92c29 --- /dev/null +++ b/examples/collection/collection.yaml @@ -0,0 +1,42 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: configmap-bar + namespace: default + labels: + foo: bar +data: + sample-key: "sample-value" + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: configmap-foo + namespace: default + labels: + foo: bar +data: + sample-key: "sample-value2" +--- +apiVersion: kubernetes.crossplane.io/v1alpha1 +kind: ObservedObjectCollection +metadata: + name: foo-collection +spec: + observeObjects: + apiVersion: v1 + kind: ConfigMap + selector: + matchLabels: + foo: bar + objectTemplate: + metadata: + labels: + l1: v1 + annotations: + a1: v1 + providerConfigRef: + name: kubernetes-provider + diff --git a/go.mod b/go.mod index 4ddb2dbb..aef6678d 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/crossplane/crossplane-runtime v1.15.0-rc.1 github.com/crossplane/crossplane-tools v0.0.0-20230925130601-628280f8bf79 github.com/google/go-cmp v0.6.0 + github.com/google/uuid v1.4.0 github.com/pkg/errors v0.9.1 github.com/spf13/pflag v1.0.5 go.uber.org/zap v1.26.0 @@ -58,7 +59,6 @@ require ( github.com/golang/protobuf v1.5.3 // indirect github.com/google/gnostic-models v0.6.8 // indirect github.com/google/gofuzz v1.2.0 // indirect - github.com/google/uuid v1.4.0 // indirect github.com/imdario/mergo v0.3.16 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect diff --git a/internal/clients/client.go b/internal/clients/client.go index 8360b580..eff1a1a0 100644 --- a/internal/clients/client.go +++ b/internal/clients/client.go @@ -14,11 +14,31 @@ limitations under the License. package clients import ( + "context" + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" "k8s.io/client-go/tools/clientcmd/api" "sigs.k8s.io/controller-runtime/pkg/client" + + xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1" + "github.com/crossplane/crossplane-runtime/pkg/resource" + + "github.com/crossplane-contrib/provider-kubernetes/apis/v1alpha1" + "github.com/crossplane-contrib/provider-kubernetes/internal/clients/azure" + "github.com/crossplane-contrib/provider-kubernetes/internal/clients/gke" +) + +const ( + errGetPC = "cannot get ProviderConfig" + errGetCreds = "cannot get credentials" + errCreateRestConfig = "cannot create new REST config using provider secret" + errExtractGoogleCredentials = "cannot extract Google Application Credentials" + errInjectGoogleCredentials = "cannot wrap REST client with Google Application Credentials" + errExtractAzureCredentials = "failed to extract Azure Application Credentials" + errInjectAzureCredentials = "failed to wrap REST client with Azure Application Credentials" ) // NewRESTConfig returns a rest config given a secret with connection information. @@ -86,3 +106,71 @@ func restConfigFromAPIConfig(c *api.Config) (*rest.Config, error) { return config, nil } + +// ClientForProvider returns the client for the given provider config +func ClientForProvider(ctx context.Context, inclusterClient client.Client, providerConfigName string) (client.Client, error) { //nolint:gocyclo + pc := &v1alpha1.ProviderConfig{} + if err := inclusterClient.Get(ctx, types.NamespacedName{Name: providerConfigName}, pc); err != nil { + return nil, errors.Wrap(err, errGetPC) + } + + var rc *rest.Config + var err error + + switch cd := pc.Spec.Credentials; cd.Source { //nolint:exhaustive + case xpv1.CredentialsSourceInjectedIdentity: + rc, err = rest.InClusterConfig() + if err != nil { + return nil, errors.Wrap(err, errCreateRestConfig) + } + default: + kc, err := resource.CommonCredentialExtractor(ctx, cd.Source, inclusterClient, cd.CommonCredentialSelectors) + if err != nil { + return nil, errors.Wrap(err, errGetCreds) + } + + if rc, err = NewRESTConfig(kc); err != nil { + return nil, errors.Wrap(err, errCreateRestConfig) + } + } + + if id := pc.Spec.Identity; id != nil { + switch id.Type { + case v1alpha1.IdentityTypeGoogleApplicationCredentials: + switch id.Source { //nolint:exhaustive + case xpv1.CredentialsSourceInjectedIdentity: + if err := gke.WrapRESTConfig(ctx, rc, nil, gke.DefaultScopes...); err != nil { + return nil, errors.Wrap(err, errInjectGoogleCredentials) + } + default: + creds, err := resource.CommonCredentialExtractor(ctx, id.Source, inclusterClient, id.CommonCredentialSelectors) + if err != nil { + return nil, errors.Wrap(err, errExtractGoogleCredentials) + } + + if err := gke.WrapRESTConfig(ctx, rc, creds, gke.DefaultScopes...); err != nil { + return nil, errors.Wrap(err, errInjectGoogleCredentials) + } + } + case v1alpha1.IdentityTypeAzureServicePrincipalCredentials: + switch id.Source { //nolint:exhaustive + case xpv1.CredentialsSourceInjectedIdentity: + return nil, errors.Errorf("%s is not supported as identity source for identity type %s", + xpv1.CredentialsSourceInjectedIdentity, v1alpha1.IdentityTypeAzureServicePrincipalCredentials) + default: + creds, err := resource.CommonCredentialExtractor(ctx, id.Source, inclusterClient, id.CommonCredentialSelectors) + if err != nil { + return nil, errors.Wrap(err, errExtractAzureCredentials) + } + + if err := azure.WrapRESTConfig(ctx, rc, creds); err != nil { + return nil, errors.Wrap(err, errInjectAzureCredentials) + } + } + default: + return nil, errors.Errorf("unknown identity type: %s", id.Type) + } + } + + return NewKubeClient(rc) +} diff --git a/internal/controller/kubernetes.go b/internal/controller/kubernetes.go index 5fb3bf49..d5601332 100644 --- a/internal/controller/kubernetes.go +++ b/internal/controller/kubernetes.go @@ -25,6 +25,7 @@ import ( "github.com/crossplane-contrib/provider-kubernetes/internal/controller/config" "github.com/crossplane-contrib/provider-kubernetes/internal/controller/object" + "github.com/crossplane-contrib/provider-kubernetes/internal/controller/observedobjectcollection" ) // Setup creates all Template controllers with the supplied logger and adds them to @@ -36,5 +37,8 @@ func Setup(mgr ctrl.Manager, o controller.Options, sanitizeSecrets bool, pollJit if err := object.Setup(mgr, o, sanitizeSecrets, pollJitter); err != nil { return err } + if err := observedobjectcollection.Setup(mgr, o, pollJitter); err != nil { + return err + } return nil } diff --git a/internal/controller/object/object.go b/internal/controller/object/object.go index 1347ac5b..b1fd63f6 100644 --- a/internal/controller/object/object.go +++ b/internal/controller/object/object.go @@ -32,7 +32,6 @@ import ( "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/json" "k8s.io/apimachinery/pkg/util/sets" - "k8s.io/client-go/rest" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" @@ -50,26 +49,17 @@ import ( "github.com/crossplane-contrib/provider-kubernetes/apis/object/v1alpha2" apisv1alpha1 "github.com/crossplane-contrib/provider-kubernetes/apis/v1alpha1" "github.com/crossplane-contrib/provider-kubernetes/internal/clients" - "github.com/crossplane-contrib/provider-kubernetes/internal/clients/azure" - "github.com/crossplane-contrib/provider-kubernetes/internal/clients/gke" ) const ( errTrackPCUsage = "cannot track ProviderConfig usage" - errGetPC = "cannot get ProviderConfig" - errGetCreds = "cannot get credentials" errGetObject = "cannot get object" errCreateObject = "cannot create object" errApplyObject = "cannot apply object" errDeleteObject = "cannot delete object" - errNotKubernetesObject = "managed resource is not an Object custom resource" - errNewKubernetesClient = "cannot create new Kubernetes client" - errFailedToCreateRestConfig = "cannot create new REST config using provider secret" - errFailedToExtractGoogleCredentials = "cannot extract Google Application Credentials" - errFailedToInjectGoogleCredentials = "cannot wrap REST client with Google Application Credentials" - errFailedToExtractAzureCredentials = "failed to extract Azure Application Credentials" - errFailedToInjectAzureCredentials = "failed to wrap REST client with Azure Application Credentials" + errNotKubernetesObject = "managed resource is not an Object custom resource" + errNewKubernetesClient = "cannot create new Kubernetes client" errGetLastApplied = "cannot get last applied" errUnmarshalTemplate = "cannot unmarshal template" @@ -100,17 +90,11 @@ func Setup(mgr ctrl.Manager, o controller.Options, sanitizeSecrets bool, pollJit reconcilerOptions := []managed.ReconcilerOption{ managed.WithExternalConnecter(&connector{ - logger: o.Logger, - sanitizeSecrets: sanitizeSecrets, - kube: mgr.GetClient(), - usage: resource.NewProviderConfigUsageTracker(mgr.GetClient(), &apisv1alpha1.ProviderConfigUsage{}), - kcfgExtractorFn: resource.CommonCredentialExtractor, - gcpExtractorFn: resource.CommonCredentialExtractor, - gcpInjectorFn: gke.WrapRESTConfig, - azureExtractorFn: resource.CommonCredentialExtractor, - azureInjectorFn: azure.WrapRESTConfig, - newRESTConfigFn: clients.NewRESTConfig, - newKubeClientFn: clients.NewKubeClient, + logger: o.Logger, + sanitizeSecrets: sanitizeSecrets, + kube: mgr.GetClient(), + usage: resource.NewProviderConfigUsageTracker(mgr.GetClient(), &apisv1alpha1.ProviderConfigUsage{}), + clientForProviderFn: clients.ClientForProvider, }), managed.WithFinalizer(&objFinalizer{client: mgr.GetClient()}), managed.WithPollInterval(o.PollInterval), @@ -150,13 +134,7 @@ type connector struct { logger logging.Logger sanitizeSecrets bool - kcfgExtractorFn func(ctx context.Context, src xpv1.CredentialsSource, c client.Client, ccs xpv1.CommonCredentialSelectors) ([]byte, error) - gcpExtractorFn func(ctx context.Context, src xpv1.CredentialsSource, c client.Client, ccs xpv1.CommonCredentialSelectors) ([]byte, error) - gcpInjectorFn func(ctx context.Context, rc *rest.Config, credentials []byte, scopes ...string) error - azureExtractorFn func(ctx context.Context, src xpv1.CredentialsSource, c client.Client, ccs xpv1.CommonCredentialSelectors) ([]byte, error) - azureInjectorFn func(ctx context.Context, rc *rest.Config, credentials []byte, scopes ...string) error - newRESTConfigFn func(kubeconfig []byte) (*rest.Config, error) - newKubeClientFn func(config *rest.Config) (client.Client, error) + clientForProviderFn func(ctx context.Context, inclusterClient client.Client, providerConfigName string) (client.Client, error) } func (c *connector) Connect(ctx context.Context, mg resource.Managed) (managed.ExternalClient, error) { //nolint:gocyclo @@ -172,70 +150,8 @@ func (c *connector) Connect(ctx context.Context, mg resource.Managed) (managed.E return nil, errors.Wrap(err, errTrackPCUsage) } - pc := &apisv1alpha1.ProviderConfig{} - if err := c.kube.Get(ctx, types.NamespacedName{Name: cr.GetProviderConfigReference().Name}, pc); err != nil { - return nil, errors.Wrap(err, errGetPC) - } - - var rc *rest.Config - var err error - - switch cd := pc.Spec.Credentials; cd.Source { //nolint:exhaustive - case xpv1.CredentialsSourceInjectedIdentity: - rc, err = rest.InClusterConfig() - if err != nil { - return nil, errors.Wrap(err, errFailedToCreateRestConfig) - } - default: - kc, err := c.kcfgExtractorFn(ctx, cd.Source, c.kube, cd.CommonCredentialSelectors) - if err != nil { - return nil, errors.Wrap(err, errGetCreds) - } - - if rc, err = c.newRESTConfigFn(kc); err != nil { - return nil, errors.Wrap(err, errFailedToCreateRestConfig) - } - } - - if id := pc.Spec.Identity; id != nil { - switch id.Type { - case apisv1alpha1.IdentityTypeGoogleApplicationCredentials: - switch id.Source { //nolint:exhaustive - case xpv1.CredentialsSourceInjectedIdentity: - if err := c.gcpInjectorFn(ctx, rc, nil, gke.DefaultScopes...); err != nil { - return nil, errors.Wrap(err, errFailedToInjectGoogleCredentials) - } - default: - creds, err := c.gcpExtractorFn(ctx, id.Source, c.kube, id.CommonCredentialSelectors) - if err != nil { - return nil, errors.Wrap(err, errFailedToExtractGoogleCredentials) - } - - if err := c.gcpInjectorFn(ctx, rc, creds, gke.DefaultScopes...); err != nil { - return nil, errors.Wrap(err, errFailedToInjectGoogleCredentials) - } - } - case apisv1alpha1.IdentityTypeAzureServicePrincipalCredentials: - switch id.Source { //nolint:exhaustive - case xpv1.CredentialsSourceInjectedIdentity: - return nil, errors.Errorf("%s is not supported as identity source for identity type %s", - xpv1.CredentialsSourceInjectedIdentity, apisv1alpha1.IdentityTypeAzureServicePrincipalCredentials) - default: - creds, err := c.azureExtractorFn(ctx, id.Source, c.kube, id.CommonCredentialSelectors) - if err != nil { - return nil, errors.Wrap(err, errFailedToExtractAzureCredentials) - } - - if err := c.azureInjectorFn(ctx, rc, creds); err != nil { - return nil, errors.Wrap(err, errFailedToInjectAzureCredentials) - } - } - default: - return nil, errors.Errorf("unknown identity type: %s", id.Type) - } - } + k, err := c.clientForProviderFn(ctx, c.kube, cr.GetProviderConfigReference().Name) - k, err := c.newKubeClientFn(rc) if err != nil { return nil, errors.Wrap(err, errNewKubernetesClient) } diff --git a/internal/controller/object/object_test.go b/internal/controller/object/object_test.go index 9b1e76e2..7e151bcd 100644 --- a/internal/controller/object/object_test.go +++ b/internal/controller/object/object_test.go @@ -32,7 +32,6 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/client-go/rest" "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" @@ -47,12 +46,7 @@ import ( ) const ( - providerName = "kubernetes-test" - providerSecretName = "kubernetes-test-secret" - providerSecretNamespace = "kubernetes-test-secret-namespace" - - providerSecretKey = "kubeconfig" - providerSecretData = "somethingsecret" + providerName = "kubernetes-test" testObjectName = "test-object" testNamespace = "test-namespace" @@ -208,11 +202,6 @@ func referenceObjectWithFinalizer(val interface{}) *unstructured.Unstructured { } func Test_connector_Connect(t *testing.T) { - secret := corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{Namespace: providerSecretNamespace, Name: providerSecretName}, - Data: map[string][]byte{providerSecretKey: []byte(providerSecretData)}, - } - providerConfig := kubernetesv1alpha1.ProviderConfig{ ObjectMeta: metav1.ObjectMeta{Name: providerName}, Spec: kubernetesv1alpha1.ProviderConfigSpec{ @@ -248,16 +237,10 @@ func Test_connector_Connect(t *testing.T) { providerConfigUnknownIdentitySource.Spec.Identity.Type = "foo" type args struct { - client client.Client - kcfgExtractorFn func(ctx context.Context, src xpv1.CredentialsSource, c client.Client, ccs xpv1.CommonCredentialSelectors) ([]byte, error) - gcpExtractorFn func(ctx context.Context, src xpv1.CredentialsSource, c client.Client, ccs xpv1.CommonCredentialSelectors) ([]byte, error) - gcpInjectorFn func(ctx context.Context, rc *rest.Config, credentials []byte, scopes ...string) error - azureExtractorFn func(ctx context.Context, src xpv1.CredentialsSource, c client.Client, ccs xpv1.CommonCredentialSelectors) ([]byte, error) - azureInjectorFn func(ctx context.Context, rc *rest.Config, credentials []byte, scopes ...string) error - newRESTConfigFn func(kubeconfig []byte) (*rest.Config, error) - newKubeClientFn func(config *rest.Config) (client.Client, error) - usage resource.Tracker - mg resource.Managed + client client.Client + clientForProvider func(ctx context.Context, inclusterClient client.Client, providerConfigName string) (client.Client, error) + usage resource.Tracker + mg resource.Managed } type want struct { err error @@ -283,322 +266,21 @@ func Test_connector_Connect(t *testing.T) { err: errors.Wrap(errBoom, errTrackPCUsage), }, }, - "FailedToGetProvider": { - args: args{ - client: &test.MockClient{ - MockGet: func(ctx context.Context, key client.ObjectKey, obj client.Object) error { - if key.Name == providerName { - *obj.(*kubernetesv1alpha1.ProviderConfig) = providerConfig - return errBoom - } - return nil - }, - }, - usage: resource.TrackerFn(func(ctx context.Context, mg resource.Managed) error { return nil }), - mg: kubernetesObject(), - }, - want: want{ - err: errors.Wrap(errBoom, errGetPC), - }, - }, - "FailedToExtractKubeconfig": { - args: args{ - client: &test.MockClient{ - MockGet: func(ctx context.Context, key client.ObjectKey, obj client.Object) error { - if key.Name == providerName { - *obj.(*kubernetesv1alpha1.ProviderConfig) = providerConfig - return nil - } - if key.Name == providerSecretName && key.Namespace == providerSecretNamespace { - *obj.(*corev1.Secret) = secret - return nil - } - return errBoom - }, - }, - kcfgExtractorFn: func(ctx context.Context, src xpv1.CredentialsSource, c client.Client, ccs xpv1.CommonCredentialSelectors) ([]byte, error) { - return nil, errBoom - }, - usage: resource.TrackerFn(func(ctx context.Context, mg resource.Managed) error { return nil }), - mg: kubernetesObject(), - }, - want: want{ - err: errors.Wrap(errBoom, errGetCreds), - }, - }, - "FailedToCreateRESTConfig": { - args: args{ - client: &test.MockClient{ - MockGet: func(ctx context.Context, key client.ObjectKey, obj client.Object) error { - if key.Name == providerName { - *obj.(*kubernetesv1alpha1.ProviderConfig) = providerConfig - return nil - } - if key.Name == providerSecretName && key.Namespace == providerSecretNamespace { - *obj.(*corev1.Secret) = secret - return nil - } - return errBoom - }, - }, - kcfgExtractorFn: func(ctx context.Context, src xpv1.CredentialsSource, c client.Client, ccs xpv1.CommonCredentialSelectors) ([]byte, error) { - return nil, nil - }, - newRESTConfigFn: func(kubeconfig []byte) (config *rest.Config, err error) { - return nil, errBoom - }, - usage: resource.TrackerFn(func(ctx context.Context, mg resource.Managed) error { return nil }), - mg: kubernetesObject(), - }, - want: want{ - err: errors.Wrap(errBoom, errFailedToCreateRestConfig), - }, - }, - "FailedToExtractGoogleCredentials": { - args: args{ - client: &test.MockClient{ - MockGet: func(ctx context.Context, key client.ObjectKey, obj client.Object) error { - if key.Name == providerName { - *obj.(*kubernetesv1alpha1.ProviderConfig) = providerConfig - return nil - } - if key.Name == providerSecretName && key.Namespace == providerSecretNamespace { - *obj.(*corev1.Secret) = secret - return nil - } - return errBoom - }, - }, - kcfgExtractorFn: func(ctx context.Context, src xpv1.CredentialsSource, c client.Client, ccs xpv1.CommonCredentialSelectors) ([]byte, error) { - return nil, nil - }, - newRESTConfigFn: func(kubeconfig []byte) (config *rest.Config, err error) { - return nil, nil - }, - gcpExtractorFn: func(ctx context.Context, src xpv1.CredentialsSource, c client.Client, ccs xpv1.CommonCredentialSelectors) ([]byte, error) { - return nil, errBoom - }, - usage: resource.TrackerFn(func(ctx context.Context, mg resource.Managed) error { return nil }), - mg: kubernetesObject(), - }, - want: want{ - err: errors.Wrap(errBoom, errFailedToExtractGoogleCredentials), - }, - }, - "FailedToInjectGoogleCredentials": { - args: args{ - client: &test.MockClient{ - MockGet: func(ctx context.Context, key client.ObjectKey, obj client.Object) error { - if key.Name == providerName { - *obj.(*kubernetesv1alpha1.ProviderConfig) = providerConfig - return nil - } - if key.Name == providerSecretName && key.Namespace == providerSecretNamespace { - *obj.(*corev1.Secret) = secret - return nil - } - return errBoom - }, - }, - kcfgExtractorFn: func(ctx context.Context, src xpv1.CredentialsSource, c client.Client, ccs xpv1.CommonCredentialSelectors) ([]byte, error) { - return nil, nil - }, - newRESTConfigFn: func(kubeconfig []byte) (config *rest.Config, err error) { - return nil, nil - }, - gcpExtractorFn: func(ctx context.Context, src xpv1.CredentialsSource, c client.Client, ccs xpv1.CommonCredentialSelectors) ([]byte, error) { - return nil, nil - }, - gcpInjectorFn: func(ctx context.Context, rc *rest.Config, credentials []byte, scopes ...string) error { - return errBoom - }, - usage: resource.TrackerFn(func(ctx context.Context, mg resource.Managed) error { return nil }), - mg: kubernetesObject(), - }, - want: want{ - err: errors.Wrap(errBoom, errFailedToInjectGoogleCredentials), - }, - }, - "FailedToInjectGoogleCredentialsWithInjectedIdentitySource": { - args: args{ - client: &test.MockClient{ - MockGet: func(ctx context.Context, key client.ObjectKey, obj client.Object) error { - if key.Name == providerName { - *obj.(*kubernetesv1alpha1.ProviderConfig) = providerConfigGoogleInjectedIdentity - return nil - } - return errBoom - }, - }, - kcfgExtractorFn: func(ctx context.Context, src xpv1.CredentialsSource, c client.Client, ccs xpv1.CommonCredentialSelectors) ([]byte, error) { - return nil, nil - }, - newRESTConfigFn: func(kubeconfig []byte) (config *rest.Config, err error) { - return nil, nil - }, - gcpExtractorFn: func(ctx context.Context, src xpv1.CredentialsSource, c client.Client, ccs xpv1.CommonCredentialSelectors) ([]byte, error) { - return nil, nil - }, - gcpInjectorFn: func(ctx context.Context, rc *rest.Config, credentials []byte, scopes ...string) error { - return errBoom - }, - usage: resource.TrackerFn(func(ctx context.Context, mg resource.Managed) error { return nil }), - mg: kubernetesObject(), - }, - want: want{ - err: errors.Wrap(errBoom, errFailedToInjectGoogleCredentials), - }, - }, - "FailedToExtractAzureCredentials": { - args: args{ - client: &test.MockClient{ - MockGet: func(ctx context.Context, key client.ObjectKey, obj client.Object) error { - if key.Name == providerName { - *obj.(*kubernetesv1alpha1.ProviderConfig) = *providerConfigAzure - return nil - } - return errBoom - }, - }, - kcfgExtractorFn: func(ctx context.Context, src xpv1.CredentialsSource, c client.Client, ccs xpv1.CommonCredentialSelectors) ([]byte, error) { - return nil, nil - }, - newRESTConfigFn: func(kubeconfig []byte) (config *rest.Config, err error) { - return nil, nil - }, - azureExtractorFn: func(ctx context.Context, src xpv1.CredentialsSource, c client.Client, ccs xpv1.CommonCredentialSelectors) ([]byte, error) { - return nil, errBoom - }, - usage: resource.TrackerFn(func(ctx context.Context, mg resource.Managed) error { return nil }), - mg: kubernetesObject(), - }, - want: want{ - err: errors.Wrap(errBoom, errFailedToExtractAzureCredentials), - }, - }, - "FailedToInjectAzureCredentials": { - args: args{ - client: &test.MockClient{ - MockGet: func(ctx context.Context, key client.ObjectKey, obj client.Object) error { - if key.Name == providerName { - *obj.(*kubernetesv1alpha1.ProviderConfig) = *providerConfigAzure - return nil - } - return errBoom - }, - }, - kcfgExtractorFn: func(ctx context.Context, src xpv1.CredentialsSource, c client.Client, ccs xpv1.CommonCredentialSelectors) ([]byte, error) { - return nil, nil - }, - newRESTConfigFn: func(kubeconfig []byte) (config *rest.Config, err error) { - return nil, nil - }, - azureExtractorFn: func(ctx context.Context, src xpv1.CredentialsSource, c client.Client, ccs xpv1.CommonCredentialSelectors) ([]byte, error) { - return nil, nil - }, - azureInjectorFn: func(ctx context.Context, rc *rest.Config, credentials []byte, scopes ...string) error { - return errBoom - }, - usage: resource.TrackerFn(func(ctx context.Context, mg resource.Managed) error { return nil }), - mg: kubernetesObject(), - }, - want: want{ - err: errors.Wrap(errBoom, errFailedToInjectAzureCredentials), - }, - }, - "AzureCredentialsInjectedIdentitySourceNotSupported": { - args: args{ - client: &test.MockClient{ - MockGet: func(ctx context.Context, key client.ObjectKey, obj client.Object) error { - if key.Name == providerName { - *obj.(*kubernetesv1alpha1.ProviderConfig) = providerConfigAzureInjectedIdentity - return nil - } - return errBoom - }, - }, - kcfgExtractorFn: func(ctx context.Context, src xpv1.CredentialsSource, c client.Client, ccs xpv1.CommonCredentialSelectors) ([]byte, error) { - return nil, nil - }, - newRESTConfigFn: func(kubeconfig []byte) (config *rest.Config, err error) { - return nil, nil - }, - azureExtractorFn: func(ctx context.Context, src xpv1.CredentialsSource, c client.Client, ccs xpv1.CommonCredentialSelectors) ([]byte, error) { - return nil, nil - }, - azureInjectorFn: func(ctx context.Context, rc *rest.Config, credentials []byte, scopes ...string) error { - return errBoom - }, - usage: resource.TrackerFn(func(ctx context.Context, mg resource.Managed) error { return nil }), - mg: kubernetesObject(), - }, - want: want{ - err: errors.Errorf("%s is not supported as identity source for identity type %s", - xpv1.CredentialsSourceInjectedIdentity, kubernetesv1alpha1.IdentityTypeAzureServicePrincipalCredentials), - }, - }, - "FailedToInjectUnknownIdentityType": { + "Success": { args: args{ - client: &test.MockClient{ - MockGet: func(ctx context.Context, key client.ObjectKey, obj client.Object) error { - if key.Name == providerName { - *obj.(*kubernetesv1alpha1.ProviderConfig) = providerConfigUnknownIdentitySource - return nil - } - return errBoom - }, - }, - kcfgExtractorFn: func(ctx context.Context, src xpv1.CredentialsSource, c client.Client, ccs xpv1.CommonCredentialSelectors) ([]byte, error) { - return nil, nil - }, - newRESTConfigFn: func(kubeconfig []byte) (config *rest.Config, err error) { - return nil, nil - }, - azureExtractorFn: func(ctx context.Context, src xpv1.CredentialsSource, c client.Client, ccs xpv1.CommonCredentialSelectors) ([]byte, error) { - return nil, nil - }, - azureInjectorFn: func(ctx context.Context, rc *rest.Config, credentials []byte, scopes ...string) error { - return errBoom + clientForProvider: func(ctx context.Context, inclusterClient client.Client, providerConfigName string) (client.Client, error) { + return &test.MockClient{}, nil }, usage: resource.TrackerFn(func(ctx context.Context, mg resource.Managed) error { return nil }), mg: kubernetesObject(), }, want: want{ - err: errors.Errorf("unknown identity type: %s", "foo"), + err: nil, }, }, - "FailedToCreateNewKubernetesClient": { + "ErrorGettingClientForProvider": { args: args{ - client: &test.MockClient{ - MockGet: func(ctx context.Context, key client.ObjectKey, obj client.Object) error { - if key.Name == providerName { - *obj.(*kubernetesv1alpha1.ProviderConfig) = providerConfig - return nil - } - if key.Name == providerSecretName && key.Namespace == providerSecretNamespace { - *obj.(*corev1.Secret) = secret - return nil - } - return errBoom - }, - MockStatusUpdate: func(ctx context.Context, obj client.Object, opts ...client.SubResourceUpdateOption) error { - return nil - }, - }, - kcfgExtractorFn: func(ctx context.Context, src xpv1.CredentialsSource, c client.Client, ccs xpv1.CommonCredentialSelectors) ([]byte, error) { - return nil, nil - }, - newRESTConfigFn: func(kubeconfig []byte) (config *rest.Config, err error) { - return &rest.Config{}, nil - }, - - gcpExtractorFn: func(ctx context.Context, src xpv1.CredentialsSource, c client.Client, ccs xpv1.CommonCredentialSelectors) ([]byte, error) { - return nil, nil - }, - gcpInjectorFn: func(ctx context.Context, rc *rest.Config, credentials []byte, scopes ...string) error { - return nil - }, - newKubeClientFn: func(config *rest.Config) (c client.Client, err error) { + clientForProvider: func(ctx context.Context, inclusterClient client.Client, providerConfigName string) (client.Client, error) { return nil, errBoom }, usage: resource.TrackerFn(func(ctx context.Context, mg resource.Managed) error { return nil }), @@ -608,60 +290,14 @@ func Test_connector_Connect(t *testing.T) { err: errors.Wrap(errBoom, errNewKubernetesClient), }, }, - "Success": { - args: args{ - client: &test.MockClient{ - MockGet: func(ctx context.Context, key client.ObjectKey, obj client.Object) error { - switch t := obj.(type) { - case *kubernetesv1alpha1.ProviderConfig: - *t = providerConfig - case *corev1.Secret: - *t = secret - default: - return errBoom - } - return nil - }, - MockStatusUpdate: func(ctx context.Context, obj client.Object, opts ...client.SubResourceUpdateOption) error { - return nil - }, - }, - kcfgExtractorFn: func(ctx context.Context, src xpv1.CredentialsSource, c client.Client, ccs xpv1.CommonCredentialSelectors) ([]byte, error) { - return nil, nil - }, - newRESTConfigFn: func(kubeconfig []byte) (config *rest.Config, err error) { - return &rest.Config{}, nil - }, - gcpExtractorFn: func(ctx context.Context, src xpv1.CredentialsSource, c client.Client, ccs xpv1.CommonCredentialSelectors) ([]byte, error) { - return nil, nil - }, - gcpInjectorFn: func(ctx context.Context, rc *rest.Config, credentials []byte, scopes ...string) error { - return nil - }, - newKubeClientFn: func(config *rest.Config) (c client.Client, err error) { - return &test.MockClient{}, nil - }, - usage: resource.TrackerFn(func(ctx context.Context, mg resource.Managed) error { return nil }), - mg: kubernetesObject(), - }, - want: want{ - err: nil, - }, - }, } for name, tc := range cases { t.Run(name, func(t *testing.T) { c := &connector{ - logger: logging.NewNopLogger(), - kube: tc.args.client, - kcfgExtractorFn: tc.args.kcfgExtractorFn, - gcpExtractorFn: tc.args.gcpExtractorFn, - gcpInjectorFn: tc.args.gcpInjectorFn, - azureExtractorFn: tc.args.azureExtractorFn, - azureInjectorFn: tc.args.azureInjectorFn, - newRESTConfigFn: tc.args.newRESTConfigFn, - newKubeClientFn: tc.args.newKubeClientFn, - usage: tc.usage, + logger: logging.NewNopLogger(), + kube: tc.args.client, + clientForProviderFn: tc.args.clientForProvider, + usage: tc.usage, } _, gotErr := c.Connect(context.Background(), tc.args.mg) if diff := cmp.Diff(tc.want.err, gotErr, test.EquateErrors()); diff != "" { diff --git a/internal/controller/observedobjectcollection/reconciler.go b/internal/controller/observedobjectcollection/reconciler.go new file mode 100644 index 00000000..619e2a80 --- /dev/null +++ b/internal/controller/observedobjectcollection/reconciler.go @@ -0,0 +1,286 @@ +/* +Copyright 2024 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 observedobjectcollection + +import ( + "context" + "crypto/sha256" + "fmt" + "math/rand" + "time" + + "github.com/pkg/errors" + kerrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/sets" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1" + "github.com/crossplane/crossplane-runtime/pkg/controller" + xperrors "github.com/crossplane/crossplane-runtime/pkg/errors" + "github.com/crossplane/crossplane-runtime/pkg/logging" + "github.com/crossplane/crossplane-runtime/pkg/meta" + "github.com/crossplane/crossplane-runtime/pkg/ratelimiter" + "github.com/crossplane/crossplane-runtime/pkg/reconciler/managed" + + "github.com/crossplane-contrib/provider-kubernetes/apis/object/v1alpha2" + "github.com/crossplane-contrib/provider-kubernetes/apis/observedobjectcollection/v1alpha1" + "github.com/crossplane-contrib/provider-kubernetes/internal/clients" +) + +const ( + errNewKubernetesClient = "cannot create new Kubernetes client" + errStatusUpdate = "cannot update status" + fieldOwner = client.FieldOwner("kubernetes.crossplane.io/observed-object-collection-controller") + membershipLabelKey = "kubernetes.crossplane.io/owned-by-collection" +) + +// Reconciler watches for ObservedObjectCollection resources +// and creates observe-only Objects for the matched items. +type Reconciler struct { + client client.Client + log logging.Logger + pollInterval func() time.Duration + clientForProvider func(ctx context.Context, inclusterClient client.Client, providerConfigName string) (client.Client, error) + observedObjectName func(collection client.Object, matchedObject client.Object) (string, error) +} + +// Setup adds a controller that reconciles ObservedObjectCollection resources. +func Setup(mgr ctrl.Manager, o controller.Options, pollJitter time.Duration) error { + name := managed.ControllerName(v1alpha1.ObservedObjectCollectionGroupKind) + + r := &Reconciler{ + client: mgr.GetClient(), + log: o.Logger, + pollInterval: func() time.Duration { + return o.PollInterval + +time.Duration((rand.Float64()-0.5)*2*float64(pollJitter)) //nolint + }, + clientForProvider: clients.ClientForProvider, + observedObjectName: observedObjectName, + } + + return ctrl.NewControllerManagedBy(mgr). + Named(name). + For(&v1alpha1.ObservedObjectCollection{}). + WithEventFilter(predicate.Or( + predicate.GenerationChangedPredicate{}, + predicate.AnnotationChangedPredicate{}, + predicate.LabelChangedPredicate{}), + ). + Complete(ratelimiter.NewReconciler(name, xperrors.WithSilentRequeueOnConflict(r), o.GlobalRateLimiter)) +} + +// Reconcile fetches objects specified by their GVK and label selector +// and creates observed-only Objects for the matches. +func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, error error) { //nolint:gocyclo + log := r.log.WithValues("request", req) + + defer func() { + if error == nil { + log.Info("Reconciled") + } else { + log.Info("Retry", "err", error) + } + }() + + c := &v1alpha1.ObservedObjectCollection{} + err := r.client.Get(ctx, req.NamespacedName, c) + + if err != nil { + if kerrors.IsNotFound(err) { + return ctrl.Result{}, nil + } + // Error reading the object - requeue the request. + return ctrl.Result{}, err + } + + if meta.WasDeleted(c) { + return ctrl.Result{}, nil + } + + if meta.IsPaused(c) { + c.Status.SetConditions(xpv1.ReconcilePaused()) + return ctrl.Result{}, errors.Wrap(r.client.Status().Update(ctx, c), errStatusUpdate) + } + + log.Info("Reconciling") + + // Get client for the referenced provider config. + clusterClient, err := r.clientForProvider(ctx, r.client, c.Spec.ProviderConfigReference.Name) + if err != nil { + werr := errors.Wrap(err, errNewKubernetesClient) + c.Status.SetConditions(xpv1.ReconcileError(werr)) + _ = r.client.Status().Update(ctx, c) + return ctrl.Result{}, werr + } + + // Fetch objects based on the set GVK and selector. + k8sobjects := &unstructured.UnstructuredList{} + k8sobjects.SetAPIVersion(c.Spec.ObserveObjects.APIVersion) + k8sobjects.SetKind(c.Spec.ObserveObjects.Kind) + selector, err := metav1.LabelSelectorAsSelector(&c.Spec.ObserveObjects.Selector) + + if err != nil { + werr := errors.Wrap(err, "error creating selector") + c.Status.SetConditions(xpv1.ReconcileError(werr)) + _ = r.client.Status().Update(ctx, c) + return ctrl.Result{}, werr + } + + lo := client.ListOptions{LabelSelector: selector, Namespace: c.Spec.ObserveObjects.Namespace} + if err := clusterClient.List(ctx, k8sobjects, &lo); err != nil { + werr := errors.Wrapf(err, "error fetching objects for GVK %v and options %v", k8sobjects.GetObjectKind().GroupVersionKind(), lo) + c.Status.SetConditions(xpv1.ReconcileError(werr)) + _ = r.client.Status().Update(ctx, c) + return ctrl.Result{}, werr + } + + // Fetch any existing counter-part observe only Objects by collection label. + ml := map[string]string{membershipLabelKey: c.Name} + ol := &v1alpha2.ObjectList{} + if err := r.client.List(ctx, ol, client.MatchingLabels(ml)); err != nil { + werr := errors.Wrapf(err, "cannot list members matching labels %v", ml) + c.Status.SetConditions(xpv1.ReconcileError(werr)) + _ = r.client.Status().Update(ctx, c) + return ctrl.Result{}, werr + } + + // Create/update observed-only Objects for all found items. + refs := sets.New[v1alpha1.ObservedObjectReference]() + for i := range k8sobjects.Items { + o := k8sobjects.Items[i] + log.Debug("creating observed object for the matched item", "gvk", o.GroupVersionKind(), "name", o.GetName()) + name, err := r.observedObjectName(c, &o) + if err != nil { + werr := errors.Wrapf(err, "error generating name for observed object, matched object: %v", o) + c.Status.SetConditions(xpv1.ReconcileError(werr)) + _ = r.client.Status().Update(ctx, c) + return ctrl.Result{}, werr + } + + // Create patch + po, err := observedObjectPatch(name, o, c) + if err != nil { + werr := errors.Wrapf(err, "error generating patch for matched object %v", o) + c.Status.SetConditions(xpv1.ReconcileError(werr)) + _ = r.client.Status().Update(ctx, c) + return ctrl.Result{}, werr + } + if err := r.client.Patch(ctx, po, client.Apply, fieldOwner, client.ForceOwnership); err != nil { + werr := errors.Wrap(err, "cannot create observed object") + c.Status.SetConditions(xpv1.ReconcileError(werr)) + _ = r.client.Status().Update(ctx, c) + return ctrl.Result{}, werr + } + + log.Debug("created observed object", "name", po.GetName()) + refs.Insert(v1alpha1.ObservedObjectReference{Name: name}) + } + + // Remove collection members that either do not exist anymore or are no match. + for i := range ol.Items { + o := ol.Items[i] + if refs.Has(v1alpha1.ObservedObjectReference{Name: o.Name}) { + continue + } + log.Debug("Removing", "name", o.Name) + if err := r.client.Delete(ctx, &ol.Items[i]); err != nil { + werr := errors.Wrapf(err, "cannot delete observed object %v", o) + c.Status.SetConditions(xpv1.ReconcileError(werr)) + _ = r.client.Status().Update(ctx, c) + return ctrl.Result{}, werr + } + } + c.Status.SetConditions(xpv1.ReconcileSuccess(), xpv1.Available()) + + c.Status.MembershipLabel = ml + + return ctrl.Result{RequeueAfter: r.pollInterval()}, r.client.Status().Update(ctx, c) +} + +func observedObjectName(collection client.Object, matchedObject client.Object) (string, error) { + // unique object identifier + k := fmt.Sprintf("%v/%s/%s", matchedObject.GetObjectKind().GroupVersionKind(), matchedObject.GetNamespace(), matchedObject.GetName()) + // Compute sha256 hash of it and take first 56 bits. + h := sha256.New() + if _, err := h.Write([]byte(k)); err != nil { + return "", err + } + kp := fmt.Sprintf("%x", h.Sum(nil))[0:7] + // append it to the collection name + return fmt.Sprintf("%s-%s", collection.GetName(), kp), nil +} + +func observedObjectPatch(name string, matchedObject unstructured.Unstructured, collection *v1alpha1.ObservedObjectCollection) (*unstructured.Unstructured, error) { + objectManifestTemplate := `{ +"kind": "%s", +"apiVersion": "%s", +"metadata": { + "name": "%s", + "namespace": "%s" +} +}` + manifest := fmt.Sprintf(objectManifestTemplate, matchedObject.GetKind(), matchedObject.GetAPIVersion(), matchedObject.GetName(), matchedObject.GetNamespace()) + observedObject := &v1alpha2.Object{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: collection.APIVersion, + Kind: collection.Kind, + Name: collection.Name, + UID: collection.UID, + }, + }, + }, + Spec: v1alpha2.ObjectSpec{ + ResourceSpec: xpv1.ResourceSpec{ + ProviderConfigReference: &collection.Spec.ProviderConfigReference, + ManagementPolicies: []xpv1.ManagementAction{xpv1.ManagementActionObserve}, + }, + ForProvider: v1alpha2.ObjectParameters{ + Manifest: runtime.RawExtension{ + Raw: []byte(manifest), + }, + }, + }, + } + labels := map[string]string{ + membershipLabelKey: collection.Name, + } + if t := collection.Spec.Template; t != nil { + for k, v := range t.Metadata.Labels { + labels[k] = v + } + if len(t.Metadata.Annotations) > 0 { + observedObject.SetAnnotations(t.Metadata.Annotations) + } + } + observedObject.SetLabels(labels) + v, err := runtime.DefaultUnstructuredConverter.ToUnstructured(observedObject) + if err != nil { + return nil, errors.Wrap(err, "cannot convert to unstructured") + } + u := &unstructured.Unstructured{Object: v} + u.SetGroupVersionKind(v1alpha2.ObjectGroupVersionKind) + u.SetName(observedObject.Name) + return u, nil +} diff --git a/internal/controller/observedobjectcollection/reconciler_test.go b/internal/controller/observedobjectcollection/reconciler_test.go new file mode 100644 index 00000000..f7faaa30 --- /dev/null +++ b/internal/controller/observedobjectcollection/reconciler_test.go @@ -0,0 +1,402 @@ +/* +Copyright 2024 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 observedobjectcollection + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "github.com/pkg/errors" + 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/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/sets" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1" + "github.com/crossplane/crossplane-runtime/pkg/logging" + "github.com/crossplane/crossplane-runtime/pkg/test" + + "github.com/crossplane-contrib/provider-kubernetes/apis/object/v1alpha2" + "github.com/crossplane-contrib/provider-kubernetes/apis/observedobjectcollection/v1alpha1" +) + +func TestReconciler(t *testing.T) { + collectionName := types.NamespacedName{Name: "col"} + errBoom := fmt.Errorf("error reading") + pollIterval := 10 * time.Second + objectAPIVersion := "v1" + objectKind := "Foo" + type args struct { + client *test.MockClient + } + type want struct { + r reconcile.Result + err error + } + cases := map[string]struct { + reason string + args args + want want + }{ + "ErrorGetCollection": { + reason: "We should return error.", + args: args{ + client: &test.MockClient{ + MockGet: func(ctx context.Context, key client.ObjectKey, obj client.Object) error { + return errBoom + }, + }, + }, + want: want{ + err: errBoom, + }, + }, + "CollectionNotFound": { + reason: "We should not return an error if the collection resource was not found.", + args: args{ + client: &test.MockClient{ + MockGet: func(ctx context.Context, key client.ObjectKey, obj client.Object) error { + return kerrors.NewNotFound(schema.GroupResource{}, "") + }, + }, + }, + }, + "CreateObservedObjects": { + reason: "Create observed-only object from the matched objects.", + args: args{ + client: &test.MockClient{ + MockGet: func(ctx context.Context, key client.ObjectKey, obj client.Object) error { + if key != collectionName { + return fmt.Errorf("Expected %v, but got %v", collectionName, key) + } + c := obj.(*v1alpha1.ObservedObjectCollection) + c.Spec = v1alpha1.ObservedObjectCollectionSpec{ + ObserveObjects: v1alpha1.ObserveObjectCriteria{ + APIVersion: objectAPIVersion, + Kind: objectKind, + Selector: metav1.LabelSelector{ + MatchLabels: map[string]string{ + "foo": "bar", + }, + }, + }, + } + c.Name = collectionName.Name + return nil + }, + MockList: func(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + if olist, ok := list.(*v1alpha2.ObjectList); ok { + olist.Items = append(olist.Items, v1alpha2.Object{ObjectMeta: metav1.ObjectMeta{Name: "col-foo0"}}, v1alpha2.Object{ObjectMeta: metav1.ObjectMeta{Name: "col-foo1"}}) + return nil + } + ulist := list.(*unstructured.UnstructuredList) + if ulist.GetAPIVersion() != "v1" || ulist.GetKind() != "Foo" { + return fmt.Errorf("Unexpected GVK %v", ulist.GroupVersionKind()) + } + for i := 0; i < 2; i++ { + item := unstructured.Unstructured{} + item.SetKind(ulist.GetKind()) + item.SetAPIVersion(ulist.GetAPIVersion()) + item.SetName(fmt.Sprintf("foo%d", i)) + item.SetUID(types.UID(uuid.New().String())) + ulist.Items = append(ulist.Items, item) + } + return nil + }, + MockPatch: func(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error { + if patch != client.Apply { + return fmt.Errorf("Expected SSA patch, but got: %v", patch) + } + u := obj.(*unstructured.Unstructured) + if u.GroupVersionKind() != v1alpha2.ObjectGroupVersionKind { + return fmt.Errorf("Expected gvk %v, but got %v", v1alpha2.ObjectGroupVersionKind, u.GroupVersionKind()) + } + if l := u.GetLabels()[membershipLabelKey]; l != collectionName.Name { + return fmt.Errorf("Expecting membership label %v but got %v", collectionName.Name, l) + } + if s := sets.New[string]("col-foo0", "col-foo1"); !s.Has(u.GetName()) { + return fmt.Errorf("Expecting one of %v, but got %v", s.UnsortedList(), u.GetName()) + } + manifest, found, err := unstructured.NestedMap(u.Object, "spec", "forProvider", "manifest") + if err != nil { + return err + } + if !found { + return fmt.Errorf("Manifest not found") + } + if apiVersion := manifest["apiVersion"]; apiVersion != objectAPIVersion { + return fmt.Errorf("Manifest apiVersion should be %v, but got: %v", objectAPIVersion, apiVersion) + } + if kind := manifest["kind"]; kind != objectKind { + return fmt.Errorf("Manifest kind should be %v, but got: %v", objectKind, kind) + } + manifestMetadataName, _, _ := unstructured.NestedString(manifest, "metadata", "name") + if s := sets.New[string]("foo0", "foo1"); !s.Has(manifestMetadataName) { + return fmt.Errorf("Expecting one of %v, but got %v", s.UnsortedList(), manifestMetadataName) + } + return nil + }, + MockStatusUpdate: func(ctx context.Context, obj client.Object, opts ...client.SubResourceUpdateOption) error { + c := obj.(*v1alpha1.ObservedObjectCollection) + if cnd := c.Status.GetCondition(xpv1.TypeSynced); cnd.Status != corev1.ConditionTrue { + panic(fmt.Sprintf("Object sync condition not true: %v", cnd.Message)) + } + if cnd := c.Status.GetCondition(xpv1.TypeReady); cnd.Status != corev1.ConditionTrue { + panic(fmt.Sprintf("Object ready condition not true: %v", cnd.Message)) + } + if v := c.Status.MembershipLabel[membershipLabelKey]; v != collectionName.Name { + panic(fmt.Sprintf("Expected membership label %v but got %v", collectionName.Name, v)) + } + return nil + }, + }, + }, + want: want{ + r: reconcile.Result{RequeueAfter: pollIterval}, + }, + }, + "RemoveNotMatchedObservedObjects": { + reason: "Remove observe-only objects that either not exist or are not matched anymore", + args: args{ + client: &test.MockClient{ + MockGet: func(ctx context.Context, key client.ObjectKey, obj client.Object) error { + if key != collectionName { + return fmt.Errorf("Expected %v, but got %v", collectionName, key) + } + c := obj.(*v1alpha1.ObservedObjectCollection) + c.Spec = v1alpha1.ObservedObjectCollectionSpec{ + ObserveObjects: v1alpha1.ObserveObjectCriteria{ + APIVersion: objectAPIVersion, + Kind: objectKind, + Selector: metav1.LabelSelector{ + MatchLabels: map[string]string{ + "foo": "bar", + }, + }, + }, + } + c.Name = collectionName.Name + return nil + }, + MockList: func(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + if olist, ok := list.(*v1alpha2.ObjectList); ok { + olist.Items = append(olist.Items, v1alpha2.Object{ObjectMeta: metav1.ObjectMeta{Name: "col-foo0"}}, v1alpha2.Object{ObjectMeta: metav1.ObjectMeta{Name: "col-foo1"}}, v1alpha2.Object{ObjectMeta: metav1.ObjectMeta{Name: "col-foo2"}}) + return nil + } + ulist := list.(*unstructured.UnstructuredList) + if ulist.GetAPIVersion() != "v1" || ulist.GetKind() != "Foo" { + return fmt.Errorf("Unexpected GVK %v", ulist.GroupVersionKind()) + } + for i := 0; i < 2; i++ { + item := unstructured.Unstructured{} + item.SetKind(ulist.GetKind()) + item.SetAPIVersion(ulist.GetAPIVersion()) + item.SetName(fmt.Sprintf("foo%d", i)) + item.SetUID(types.UID(uuid.New().String())) + ulist.Items = append(ulist.Items, item) + } + return nil + }, + MockDelete: func(ctx context.Context, obj client.Object, opts ...client.DeleteOption) error { + if obj.GetName() != "col-foo2" { + return fmt.Errorf("Expected to remove col-foo2, but got %v", obj.GetName()) + } + return nil + }, + MockPatch: func(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error { + if patch != client.Apply { + return fmt.Errorf("Expected SSA patch, but got: %v", patch) + } + u := obj.(*unstructured.Unstructured) + if u.GroupVersionKind() != v1alpha2.ObjectGroupVersionKind { + return fmt.Errorf("Expected gvk %v, but got %v", v1alpha2.ObjectGroupVersionKind, u.GroupVersionKind()) + } + if s := sets.New[string]("col-foo0", "col-foo1"); !s.Has(u.GetName()) { + return fmt.Errorf("Expecting one of %v, but got %v", s.UnsortedList(), u.GetName()) + } + manifest, found, err := unstructured.NestedMap(u.Object, "spec", "forProvider", "manifest") + if err != nil { + return err + } + if !found { + return fmt.Errorf("Manifest not found") + } + if apiVersion := manifest["apiVersion"]; apiVersion != objectAPIVersion { + return fmt.Errorf("Manifest apiVersion should be %v, but got: %v", objectAPIVersion, apiVersion) + } + if kind := manifest["kind"]; kind != objectKind { + return fmt.Errorf("Manifest kind should be %v, but got: %v", objectKind, kind) + } + manifestMetadataName, _, _ := unstructured.NestedString(manifest, "metadata", "name") + if s := sets.New[string]("foo0", "foo1"); !s.Has(manifestMetadataName) { + return fmt.Errorf("Expecting one of %v, but got %v", s.UnsortedList(), manifestMetadataName) + } + return nil + }, + MockStatusUpdate: func(ctx context.Context, obj client.Object, opts ...client.SubResourceUpdateOption) error { + c := obj.(*v1alpha1.ObservedObjectCollection) + if cnd := c.Status.GetCondition(xpv1.TypeSynced); cnd.Status != corev1.ConditionTrue { + return fmt.Errorf("Object sync condition not true: %v", cnd.Message) + } + if cnd := c.Status.GetCondition(xpv1.TypeReady); cnd.Status != corev1.ConditionTrue { + return fmt.Errorf("Object ready condition not true: %v", cnd.Message) + } + if v := c.Status.MembershipLabel[membershipLabelKey]; v != collectionName.Name { + return fmt.Errorf("Expected membership label %v but got %v", collectionName.Name, v) + } + return nil + }, + }, + }, + want: want{ + r: reconcile.Result{RequeueAfter: pollIterval}, + }, + }, + "ErrorListingObjects": { + reason: "Return error and set collection status if listing objects produces error.", + args: args{ + client: &test.MockClient{ + MockGet: func(ctx context.Context, key client.ObjectKey, obj client.Object) error { + if key != collectionName { + return fmt.Errorf("Expected %v, but got %v", collectionName, key) + } + c := obj.(*v1alpha1.ObservedObjectCollection) + c.Spec = v1alpha1.ObservedObjectCollectionSpec{ + ObserveObjects: v1alpha1.ObserveObjectCriteria{ + APIVersion: objectAPIVersion, + Kind: objectKind, + Selector: metav1.LabelSelector{ + MatchLabels: map[string]string{ + "foo": "bar", + }, + }, + }, + } + c.Name = collectionName.Name + return nil + }, + MockList: func(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + return errBoom + }, + MockStatusUpdate: func(ctx context.Context, obj client.Object, opts ...client.SubResourceUpdateOption) error { + c := obj.(*v1alpha1.ObservedObjectCollection) + if cnd := c.Status.GetCondition(xpv1.TypeSynced); cnd.Status != corev1.ConditionFalse { + panic(fmt.Sprintf("Object sync condition not true: %v", cnd.Message)) + } + + return nil + }, + }, + }, + want: want{ + err: errBoom, + }, + }, + "ErrorCreatingObservedObjects": { + reason: "Return error and update collection status if error occurs while creating observe only object", + args: args{ + client: &test.MockClient{ + MockGet: func(ctx context.Context, key client.ObjectKey, obj client.Object) error { + if key != collectionName { + return fmt.Errorf("Expected %v, but got %v", collectionName, key) + } + c := obj.(*v1alpha1.ObservedObjectCollection) + c.Spec = v1alpha1.ObservedObjectCollectionSpec{ + ObserveObjects: v1alpha1.ObserveObjectCriteria{ + APIVersion: objectAPIVersion, + Kind: objectKind, + Selector: metav1.LabelSelector{ + MatchLabels: map[string]string{ + "foo": "bar", + }, + }, + }, + } + c.Name = collectionName.Name + return nil + }, + MockList: func(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + if olist, ok := list.(*v1alpha2.ObjectList); ok { + olist.Items = append(olist.Items, v1alpha2.Object{ObjectMeta: metav1.ObjectMeta{Name: "col-foo0"}}, v1alpha2.Object{ObjectMeta: metav1.ObjectMeta{Name: "col-foo1"}}) + return nil + } + ulist := list.(*unstructured.UnstructuredList) + if ulist.GetAPIVersion() != "v1" || ulist.GetKind() != "Foo" { + return fmt.Errorf("Unexpected GVK %v", ulist.GroupVersionKind()) + } + for i := 0; i < 2; i++ { + item := unstructured.Unstructured{} + item.SetKind(ulist.GetKind()) + item.SetAPIVersion(ulist.GetAPIVersion()) + item.SetName(fmt.Sprintf("foo%d", i)) + item.SetUID(types.UID(uuid.New().String())) + ulist.Items = append(ulist.Items, item) + } + return nil + }, + MockPatch: func(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error { + return errBoom + }, + MockStatusUpdate: func(ctx context.Context, obj client.Object, opts ...client.SubResourceUpdateOption) error { + c := obj.(*v1alpha1.ObservedObjectCollection) + if cnd := c.Status.GetCondition(xpv1.TypeSynced); cnd.Status != corev1.ConditionFalse { + panic(fmt.Sprintf("Object sync condition not true: %v", cnd.Message)) + } + return nil + }, + }, + }, + want: want{ + err: errBoom, + }, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + r := &Reconciler{ + client: tc.args.client, + log: logging.NewNopLogger(), + clientForProvider: func(ctx context.Context, inclusterClient client.Client, providerConfigName string) (client.Client, error) { + return tc.args.client, nil + }, + observedObjectName: func(collection client.Object, matchedObject client.Object) (string, error) { + return fmt.Sprintf("%s-%s", collection.GetName(), matchedObject.GetName()), nil + }, + pollInterval: func() time.Duration { + return pollIterval + }, + } + got, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: collectionName}) + if !errors.Is(err, tc.want.err) { + t.Errorf("\n%s\nr.Reconcile(...): want error: %v, got error: %v", tc.reason, tc.want.err, err) + } + if diff := cmp.Diff(tc.want.r, got, test.EquateErrors()); diff != "" { + t.Errorf("\n%s\nr.Reconcile(...): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/package/crds/kubernetes.crossplane.io_observedobjectcollections.yaml b/package/crds/kubernetes.crossplane.io_observedobjectcollections.yaml new file mode 100644 index 00000000..59384dbe --- /dev/null +++ b/package/crds/kubernetes.crossplane.io_observedobjectcollections.yaml @@ -0,0 +1,257 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.14.0 + name: observedobjectcollections.kubernetes.crossplane.io +spec: + group: kubernetes.crossplane.io + names: + categories: + - crossplane + - managed + - kubernetes + kind: ObservedObjectCollection + listKind: ObservedObjectCollectionList + plural: observedobjectcollections + singular: observedobjectcollection + scope: Cluster + versions: + - additionalPrinterColumns: + - jsonPath: .spec.kind + name: KIND + type: string + - jsonPath: .spec.apiVersion + name: APIVERSION + priority: 1 + type: string + - jsonPath: .spec.providerConfigRef.name + name: PROVIDERCONFIG + type: string + - jsonPath: .status.conditions[?(@.type=='Synced')].status + name: SYNCED + 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 ObservedObjectCollection is a provider Kubernetes API type + 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: ObservedObjectCollectionSpec defines the desired state of + ObservedObjectCollection + properties: + objectTemplate: + description: Template when defined is used for creating Object instances + properties: + metadata: + description: Objects metadata + properties: + annotations: + additionalProperties: + type: string + description: Annotations of an object + type: object + labels: + additionalProperties: + type: string + description: Labels of an object + type: object + type: object + type: object + observeObjects: + description: |- + ObserveObjects declares what criteria object need to fulfil + to become a member of this collection + properties: + apiVersion: + description: APIVersion of objects that should be matched by the + selector + minLength: 1 + type: string + kind: + description: Kind of objects that should be matched by the selector + minLength: 1 + type: string + namespace: + description: |- + Namespace where to look for objects. + If omitted, search is performed across all namespaces. + For cluster-scoped objects, omit it. + type: string + selector: + description: Selector defines the criteria for including objects + into the collection + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + required: + - apiVersion + - kind + - selector + type: object + providerConfigRef: + default: + name: default + description: |- + ProviderConfigReference specifies how the provider that will be used to + create, observe, update, and delete this managed resource should be + configured. + properties: + name: + description: Name of the referenced object. + type: string + policy: + description: Policies for referencing. + properties: + resolution: + default: Required + description: |- + Resolution specifies whether resolution of this reference is required. + The default is 'Required', which means the reconcile will fail if the + reference cannot be resolved. 'Optional' means this reference will be + a no-op if it cannot be resolved. + enum: + - Required + - Optional + type: string + resolve: + description: |- + Resolve specifies when this reference should be resolved. The default + is 'IfNotPresent', which will attempt to resolve the reference only when + the corresponding field is not present. Use 'Always' to resolve the + reference on every reconcile. + enum: + - Always + - IfNotPresent + type: string + type: object + required: + - name + type: object + required: + - observeObjects + type: object + status: + description: ObservedObjectCollectionStatus represents the observed state + of a ObservedObjectCollection + 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 + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + membershipLabel: + additionalProperties: + type: string + description: |- + MembershipLabel is the label set on each member of this collection + and can be used for fetching them. + type: object + type: object + required: + - spec + type: object + x-kubernetes-validations: + - message: metadata.name max length is 63 + rule: size(self.metadata.name) < 64 + served: true + storage: true + subresources: + status: {}