diff --git a/Makefile b/Makefile index 0ec5f712d5..2ae17e11dc 100644 --- a/Makefile +++ b/Makefile @@ -67,8 +67,8 @@ lint: licensecheck go-lint oas-lint .PHONY: crd crd: controller-gen-install ${CONTROLLER_GEN} object crd:crdVersions=v1 paths="./endpoint/..." - ${CONTROLLER_GEN} object crd:crdVersions=v1 paths="./apis/..." output:crd:stdout > config/crd/standard/dnsendpoint.yaml - cp -f config/crd/standard/dnsendpoint.yaml charts/external-dns/crds/dnsendpoint.yaml + ${CONTROLLER_GEN} object crd:crdVersions=v1 paths="./apis/..." output:crd:stdout > config/crd/standard/v1alpha1.yaml + cp -f config/crd/standard/v1alpha1.yaml charts/external-dns/crds/ #? test: The verify target runs tasks similar to the CI tasks, but without code coverage .PHONY: test diff --git a/apis/v1alpha1/api.go b/apis/v1alpha1/api.go index 127a773689..406a44802e 100644 --- a/apis/v1alpha1/api.go +++ b/apis/v1alpha1/api.go @@ -1,5 +1,5 @@ /* -Copyright 2017 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/apis/v1alpha1/dnsendpoint.go b/apis/v1alpha1/dnsendpoint.go index 522bd1beeb..689224bc77 100644 --- a/apis/v1alpha1/dnsendpoint.go +++ b/apis/v1alpha1/dnsendpoint.go @@ -1,5 +1,5 @@ /* -Copyright 2017 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/apis/v1alpha1/dnsrecord.go b/apis/v1alpha1/dnsrecord.go new file mode 100644 index 0000000000..07e125366e --- /dev/null +++ b/apis/v1alpha1/dnsrecord.go @@ -0,0 +1,91 @@ +/* +Copyright 2025 The Kubernetes 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 ( + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "sigs.k8s.io/external-dns/endpoint" +) + +const ( + RecordOwnerLabel string = "externaldns.k8s.io/owner" + RecordNameLabel string = "externaldns.k8s.io/record-name" + RecordTypeLabel string = "externaldns.k8s.io/record-type" + RecordIdentifierLabel string = "externaldns.k8s.io/identifier" + RecordResourceLabel string = "externaldns.k8s.io/resource" +) + +// DNSRecordSpec defines the desired state of DNSEndpoint +// +kubebuilder:object:generate=true +type DNSRecordSpec struct { + Endpoint endpoint.Endpoint `json:"endpoints,omitempty"` +} + +// DNSRecordStatus defines the observed state of DNSRecord +// +kubebuilder:object:generate=true +type DNSRecordStatus struct { + // The generation observed by the external-dns controller. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` +} + +// +genclient +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// DNSRecord is used to get all records managed by external-dns. +// It can be used as a registry with the status subresource. +// +k8s:openapi-gen=true +// +groupName=externaldns.k8s.io +// +kubebuilder:resource:path=dnsrecords +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +versionName=v1alpha1 + +type DNSRecord struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec DNSRecordSpec `json:"spec,omitempty"` + Status DNSRecordStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true +// DNSEndpointList is a list of DNSEndpoint objects +type DNSRecordList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []DNSRecord `json:"items"` +} + +func (dr DNSRecord) IsEndpoint(e *endpoint.Endpoint) bool { + spec := dr.Spec.Endpoint + + return spec.DNSName == strings.ToLower(e.DNSName) && + spec.RecordType == e.RecordType && + spec.SetIdentifier == e.SetIdentifier +} + +func (dr DNSRecord) EndpointLabels() endpoint.Labels { + labels := endpoint.Labels{} + + labels[endpoint.OwnerLabelKey] = dr.Labels[RecordOwnerLabel] + labels[endpoint.ResourceLabelKey] = dr.Labels[RecordResourceLabel] + return labels +} diff --git a/apis/v1alpha1/groupversion_info.go b/apis/v1alpha1/groupversion_info.go index 926c4bc927..b61ff0b5cc 100644 --- a/apis/v1alpha1/groupversion_info.go +++ b/apis/v1alpha1/groupversion_info.go @@ -1,5 +1,5 @@ /* -Copyright 2017 The Kubernetes Authors. +Copyright 2025 The Kubernetes Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/apis/v1alpha1/zz_generated.deepcopy.go b/apis/v1alpha1/zz_generated.deepcopy.go index e2f5bf1b85..79d884671b 100644 --- a/apis/v1alpha1/zz_generated.deepcopy.go +++ b/apis/v1alpha1/zz_generated.deepcopy.go @@ -108,3 +108,93 @@ func (in *DNSEndpointStatus) DeepCopy() *DNSEndpointStatus { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSRecord) DeepCopyInto(out *DNSRecord) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + out.Status = in.Status +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSRecord. +func (in *DNSRecord) DeepCopy() *DNSRecord { + if in == nil { + return nil + } + out := new(DNSRecord) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DNSRecord) 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 *DNSRecordList) DeepCopyInto(out *DNSRecordList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]DNSRecord, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSRecordList. +func (in *DNSRecordList) DeepCopy() *DNSRecordList { + if in == nil { + return nil + } + out := new(DNSRecordList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DNSRecordList) 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 *DNSRecordSpec) DeepCopyInto(out *DNSRecordSpec) { + *out = *in + in.Endpoint.DeepCopyInto(&out.Endpoint) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSRecordSpec. +func (in *DNSRecordSpec) DeepCopy() *DNSRecordSpec { + if in == nil { + return nil + } + out := new(DNSRecordSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DNSRecordStatus) DeepCopyInto(out *DNSRecordStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DNSRecordStatus. +func (in *DNSRecordStatus) DeepCopy() *DNSRecordStatus { + if in == nil { + return nil + } + out := new(DNSRecordStatus) + in.DeepCopyInto(out) + return out +} diff --git a/charts/external-dns/crds/dnsendpoint.yaml b/charts/external-dns/crds/v1alpha1.yaml similarity index 52% rename from charts/external-dns/crds/dnsendpoint.yaml rename to charts/external-dns/crds/v1alpha1.yaml index 83388d451b..3be572443b 100644 --- a/charts/external-dns/crds/dnsendpoint.yaml +++ b/charts/external-dns/crds/v1alpha1.yaml @@ -101,3 +101,100 @@ spec: storage: true subresources: status: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.2 + name: dnsrecords.externaldns.k8s.io +spec: + group: externaldns.k8s.io + names: + kind: DNSRecord + listKind: DNSRecordList + plural: dnsrecords + singular: dnsrecord + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + 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: DNSRecordSpec defines the desired state of DNSEndpoint + properties: + endpoints: + description: Endpoint is a high-level way of a connection between + a service and an IP + properties: + dnsName: + description: The hostname of the DNS record + type: string + labels: + additionalProperties: + type: string + description: Labels stores labels defined for the Endpoint + type: object + providerSpecific: + description: ProviderSpecific stores provider specific config + items: + description: ProviderSpecificProperty holds the name and value + of a configuration which is specific to individual DNS providers + properties: + name: + type: string + value: + type: string + type: object + type: array + recordTTL: + description: TTL for the record + format: int64 + type: integer + recordType: + description: RecordType type of record, e.g. CNAME, A, AAAA, SRV, + TXT etc + type: string + setIdentifier: + description: Identifier to distinguish multiple records with the + same name and type (e.g. Route53 records with routing policies + other than 'simple') + type: string + targets: + description: The targets the DNS record points to + items: + type: string + type: array + type: object + type: object + status: + description: DNSRecordStatus defines the observed state of DNSRecord + properties: + observedGeneration: + description: The generation observed by the external-dns controller. + format: int64 + type: integer + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/standard/dnsendpoint.yaml b/config/crd/standard/v1alpha1.yaml similarity index 52% rename from config/crd/standard/dnsendpoint.yaml rename to config/crd/standard/v1alpha1.yaml index 83388d451b..3be572443b 100644 --- a/config/crd/standard/dnsendpoint.yaml +++ b/config/crd/standard/v1alpha1.yaml @@ -101,3 +101,100 @@ spec: storage: true subresources: status: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.2 + name: dnsrecords.externaldns.k8s.io +spec: + group: externaldns.k8s.io + names: + kind: DNSRecord + listKind: DNSRecordList + plural: dnsrecords + singular: dnsrecord + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + 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: DNSRecordSpec defines the desired state of DNSEndpoint + properties: + endpoints: + description: Endpoint is a high-level way of a connection between + a service and an IP + properties: + dnsName: + description: The hostname of the DNS record + type: string + labels: + additionalProperties: + type: string + description: Labels stores labels defined for the Endpoint + type: object + providerSpecific: + description: ProviderSpecific stores provider specific config + items: + description: ProviderSpecificProperty holds the name and value + of a configuration which is specific to individual DNS providers + properties: + name: + type: string + value: + type: string + type: object + type: array + recordTTL: + description: TTL for the record + format: int64 + type: integer + recordType: + description: RecordType type of record, e.g. CNAME, A, AAAA, SRV, + TXT etc + type: string + setIdentifier: + description: Identifier to distinguish multiple records with the + same name and type (e.g. Route53 records with routing policies + other than 'simple') + type: string + targets: + description: The targets the DNS record points to + items: + type: string + type: array + type: object + type: object + status: + description: DNSRecordStatus defines the observed state of DNSRecord + properties: + observedGeneration: + description: The generation observed by the external-dns controller. + format: int64 + type: integer + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/controller/execute.go b/controller/execute.go index c4dcd56120..547074e415 100644 --- a/controller/execute.go +++ b/controller/execute.go @@ -382,9 +382,13 @@ func configureLogger(cfg *externaldns.Config) { // It initializes and returns a registry along with any error encountered during setup. // Supported registry types include: dynamodb, noop, txt, and aws-sd. func selectRegistry(cfg *externaldns.Config, p provider.Provider) (registry.Registry, error) { - var r registry.Registry + var reg registry.Registry var err error switch cfg.Registry { + case "aws-sd": + reg, err = registry.NewAWSSDRegistry(p, cfg.TXTOwnerID) + case "crd": + reg, err = registry.NewCRDRegistry(p, cfg.KubeConfig, cfg.APIServerURL, cfg.CRDSourceAPIVersion, cfg.Namespace, cfg.TXTOwnerID, cfg.RequestTimeout, cfg.TXTCacheInterval) case "dynamodb": var dynamodbOpts []func(*dynamodb.Options) if cfg.AWSDynamoDBRegion != "" { @@ -394,17 +398,16 @@ func selectRegistry(cfg *externaldns.Config, p provider.Provider) (registry.Regi }, } } - r, err = registry.NewDynamoDBRegistry(p, cfg.TXTOwnerID, dynamodb.NewFromConfig(aws.CreateDefaultV2Config(cfg), dynamodbOpts...), cfg.AWSDynamoDBTable, cfg.TXTPrefix, cfg.TXTSuffix, cfg.TXTWildcardReplacement, cfg.ManagedDNSRecordTypes, cfg.ExcludeDNSRecordTypes, []byte(cfg.TXTEncryptAESKey), cfg.TXTCacheInterval) + reg, err = registry.NewDynamoDBRegistry(p, cfg.TXTOwnerID, dynamodb.NewFromConfig(aws.CreateDefaultV2Config(cfg), dynamodbOpts...), cfg.AWSDynamoDBTable, cfg.TXTPrefix, cfg.TXTSuffix, cfg.TXTWildcardReplacement, cfg.ManagedDNSRecordTypes, cfg.ExcludeDNSRecordTypes, []byte(cfg.TXTEncryptAESKey), cfg.TXTCacheInterval) case "noop": - r, err = registry.NewNoopRegistry(p) + reg, err = registry.NewNoopRegistry(p) case "txt": - r, err = registry.NewTXTRegistry(p, cfg.TXTPrefix, cfg.TXTSuffix, cfg.TXTOwnerID, cfg.TXTCacheInterval, cfg.TXTWildcardReplacement, cfg.ManagedDNSRecordTypes, cfg.ExcludeDNSRecordTypes, cfg.TXTEncryptEnabled, []byte(cfg.TXTEncryptAESKey), cfg.TXTNewFormatOnly) - case "aws-sd": - r, err = registry.NewAWSSDRegistry(p, cfg.TXTOwnerID) + reg, err = registry.NewTXTRegistry(p, cfg.TXTPrefix, cfg.TXTSuffix, cfg.TXTOwnerID, cfg.TXTCacheInterval, cfg.TXTWildcardReplacement, cfg.ManagedDNSRecordTypes, cfg.ExcludeDNSRecordTypes, cfg.TXTEncryptEnabled, []byte(cfg.TXTEncryptAESKey), cfg.TXTNewFormatOnly) + default: log.Fatalf("unknown registry: %s", cfg.Registry) } - return r, err + return reg, err } // RegexDomainFilter overrides DomainFilter diff --git a/docs/flags.md b/docs/flags.md index 7211ecb7e4..8635b37a19 100644 --- a/docs/flags.md +++ b/docs/flags.md @@ -156,7 +156,7 @@ | `--plural-cluster=""` | When using the plural provider, specify the cluster name you're running with | | `--plural-provider=""` | When using the plural provider, specify the provider name you're running with | | `--policy=sync` | Modify how DNS records are synchronized between sources and providers (default: sync, options: sync, upsert-only, create-only) | -| `--registry=txt` | The registry implementation to use to keep track of DNS record ownership (default: txt, options: txt, noop, dynamodb, aws-sd) | +| `--registry=txt` | The registry implementation to use to keep track of DNS record ownership (default: txt, options: aws-sd, crd, dynamodb, noop, txt) | | `--txt-owner-id="default"` | When using the TXT or DynamoDB registry, a name that identifies this instance of ExternalDNS (default: default) | | `--txt-prefix=""` | When using the TXT registry, a custom string that's prefixed to each ownership DNS record (optional). Could contain record type template like '%{record_type}-prefix-'. Mutual exclusive with txt-suffix! | | `--txt-suffix=""` | When using the TXT registry, a custom string that's suffixed to the host portion of each ownership DNS record (optional). Could contain record type template like '-%{record_type}-suffix'. Mutual exclusive with txt-prefix! | diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index 635c8aedc9..e9761ff9d9 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -612,7 +612,7 @@ func App(cfg *Config) *kingpin.Application { app.Flag("policy", "Modify how DNS records are synchronized between sources and providers (default: sync, options: sync, upsert-only, create-only)").Default(defaultConfig.Policy).EnumVar(&cfg.Policy, "sync", "upsert-only", "create-only") // Flags related to the registry - app.Flag("registry", "The registry implementation to use to keep track of DNS record ownership (default: txt, options: txt, noop, dynamodb, aws-sd)").Default(defaultConfig.Registry).EnumVar(&cfg.Registry, "txt", "noop", "dynamodb", "aws-sd") + app.Flag("registry", "The registry implementation to use to keep track of DNS record ownership (default: txt, options: aws-sd, crd, dynamodb, noop, txt)").Default(defaultConfig.Registry).EnumVar(&cfg.Registry, "aws-sd", "crd", "dynamodb", "noop", "txt") app.Flag("txt-owner-id", "When using the TXT or DynamoDB registry, a name that identifies this instance of ExternalDNS (default: default)").Default(defaultConfig.TXTOwnerID).StringVar(&cfg.TXTOwnerID) app.Flag("txt-prefix", "When using the TXT registry, a custom string that's prefixed to each ownership DNS record (optional). Could contain record type template like '%{record_type}-prefix-'. Mutual exclusive with txt-suffix!").Default(defaultConfig.TXTPrefix).StringVar(&cfg.TXTPrefix) app.Flag("txt-suffix", "When using the TXT registry, a custom string that's suffixed to the host portion of each ownership DNS record (optional). Could contain record type template like '-%{record_type}-suffix'. Mutual exclusive with txt-prefix!").Default(defaultConfig.TXTSuffix).StringVar(&cfg.TXTSuffix) diff --git a/registry/crd.go b/registry/crd.go new file mode 100644 index 0000000000..8703ea8fdd --- /dev/null +++ b/registry/crd.go @@ -0,0 +1,476 @@ +/* +Copyright 2025 The Kubernetes 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 registry + +import ( + "context" + "errors" + "fmt" + "os" + "time" + + log "github.com/sirupsen/logrus" + k8sErrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + + apiv1alpha1 "sigs.k8s.io/external-dns/apis/v1alpha1" + "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/plan" + "sigs.k8s.io/external-dns/provider" + "sigs.k8s.io/external-dns/source" +) + +type CRDConfig struct { + KubeConfig string + APIServerURL string + APIVersion string + Kind string +} + +// The CRD interfaces are built as k8s' rest.Interface doesn't have proper support for testing +// These interfaces exists so the runtime will use the rest.Interface but gives an +// option for writing tests without building a complete k8s client. +type CRDClient interface { + Get() CRDRequest + List() CRDRequest + Put() CRDRequest + Post() CRDRequest + Delete() CRDRequest +} + +type CRDRequest interface { + Name(string) CRDRequest + Namespace(string) CRDRequest + Body(interface{}) CRDRequest + Params(runtime.Object) CRDRequest + Do(context.Context) CRDResult +} + +type CRDResult interface { + Error() error + Into(runtime.Object) error +} + +// CRDRegistry implements registry interface with ownership implemented via associated custom resource records (DNSRecord) +type CRDRegistry struct { + client CRDClient + namespace string + provider provider.Provider + ownerID string // refers to the owner id of the current instance + + // cache the records in memory and update on an interval instead. + recordsCache []*endpoint.Endpoint + recordsCacheRefreshTime time.Time + cacheInterval time.Duration +} + +// NewCRDClientForAPIVersionKind return rest client for the given apiVersion and kind of the CRD +func NewCRDClientForAPIVersionKind(client kubernetes.Interface, kubeConfig, apiServerURL, apiVersion string) (CRDClient, error) { + if kubeConfig == "" { + if _, err := os.Stat(clientcmd.RecommendedHomeFile); err == nil { + kubeConfig = clientcmd.RecommendedHomeFile + } + } + + config, err := clientcmd.BuildConfigFromFlags(apiServerURL, kubeConfig) + if err != nil { + return nil, err + } + + groupVersion, err := schema.ParseGroupVersion(apiVersion) + if err != nil { + return nil, err + } + + scheme := runtime.NewScheme() + scheme.AddKnownTypes(groupVersion, + &apiv1alpha1.DNSRecord{}, + &apiv1alpha1.DNSRecordList{}, + ) + metav1.AddToGroupVersion(scheme, groupVersion) + + config.GroupVersion = &groupVersion + config.APIPath = "/apis" + config.NegotiatedSerializer = serializer.WithoutConversionCodecFactory{CodecFactory: serializer.NewCodecFactory(scheme)} + + crdClient, err := rest.UnversionedRESTClientFor(config) + if err != nil { + return nil, err + } + + apiResourceList, err := client.Discovery().ServerResourcesForGroupVersion(groupVersion.String()) + if err != nil { + return nil, fmt.Errorf("error listing resources in GroupVersion %q: %w", groupVersion.String(), err) + } + + var crdAPIResource *metav1.APIResource + for _, apiResource := range apiResourceList.APIResources { + if apiResource.Kind == "DNSRecord" { + crdAPIResource = &apiResource + break + } + } + if crdAPIResource == nil { + return nil, fmt.Errorf("unable to find Resource Kind %q in GroupVersion %q", "DNSRecord", apiVersion) + } + return &crdclient{scheme: scheme, resource: crdAPIResource, codec: runtime.NewParameterCodec(scheme), Interface: crdClient}, nil +} + +// NewCRDRegistry returns new CRDRegistry object +func NewCRDRegistry(provider provider.Provider, kubeConfig, apiServerURL, apiVersion, namespace, ownerID string, cacheInterval, apiServerTimeOut time.Duration) (*CRDRegistry, error) { + var err error + var k8sClient kubernetes.Interface + + if ownerID == "" { + return nil, errors.New("owner id cannot be empty") + } + + if namespace == "" { + log.Info("Registry: namespace not specified, using `default`") + namespace = "default" + } + + // new Singleton because the user may want to store this registry on a + // remote (and shared) cluster between multiple external-dns instances + clientGenerator := &source.SingletonClientGenerator{ + KubeConfig: kubeConfig, + APIServerURL: apiServerURL, + // If update events are enabled, disable timeout. + RequestTimeout: func() time.Duration { + return apiServerTimeOut + }(), + } + + k8sClient, err = clientGenerator.KubeClient() + if err != nil { + return nil, fmt.Errorf("unable to create kubeclient: %w", err) + } + + crdClient, err := NewCRDClientForAPIVersionKind(k8sClient, kubeConfig, apiServerURL, apiVersion) + if err != nil { + return nil, fmt.Errorf("unable to create crdclient: %w", err) + } + + return &CRDRegistry{ + client: crdClient, + namespace: namespace, + provider: provider, + ownerID: ownerID, + cacheInterval: cacheInterval, + }, nil +} + +func (cr *CRDRegistry) GetDomainFilter() endpoint.DomainFilterInterface { + return cr.provider.GetDomainFilter() +} + +func (cr *CRDRegistry) OwnerID() string { + return cr.ownerID +} + +// Records returns the current records from the registry +func (cr *CRDRegistry) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { + // If we have the zones cached AND we have refreshed the cache since the + // last given interval, then just use the cached results. + if cr.recordsCache != nil && time.Since(cr.recordsCacheRefreshTime) < cr.cacheInterval { + log.Debug("Using cached records.") + return cr.recordsCache, nil + } + + records, err := cr.provider.Records(ctx) + if err != nil { + return nil, err + } + + endpoints := []*endpoint.Endpoint{} + + for _, record := range records { + // AWS Alias records have "new" format encoded as type "cname" + if isAlias, found := record.GetProviderSpecificProperty("alias"); found && isAlias == "true" && record.RecordType == endpoint.RecordTypeA { + record.RecordType = endpoint.RecordTypeCNAME + } + + endpoints = append(endpoints, record) + } + + var list apiv1alpha1.DNSRecordList + for more := true; more; more = list.Continue != "" { + opts := metav1.ListOptions{ + LabelSelector: fmt.Sprintf("%s=%s", apiv1alpha1.RecordOwnerLabel, cr.ownerID), + } + + if list.Continue != "" { + opts.Continue = list.Continue + } + + // Populate the labels for each record with the DNSRecord matching. + err = cr.client.Get().Namespace(cr.namespace).Params(&opts).Do(ctx).Into(&list) + if err != nil { + return nil, err + } + + for _, record := range list.Items { + for _, endpoint := range endpoints { + if record.IsEndpoint(endpoint) { + endpoint.Labels = record.EndpointLabels() + } + } + } + } + + // Update the cache. + if cr.cacheInterval > 0 { + cr.recordsCache = endpoints + cr.recordsCacheRefreshTime = time.Now() + } + + return endpoints, nil +} + +// ApplyChanges updates dns provider with the changes and creates/updates/delete a DNSRecord accordingly. +func (cr *CRDRegistry) ApplyChanges(ctx context.Context, changes *plan.Changes) error { + filteredChanges := &plan.Changes{ + Create: changes.Create, + UpdateNew: endpoint.FilterEndpointsByOwnerID(cr.ownerID, changes.UpdateNew), + UpdateOld: endpoint.FilterEndpointsByOwnerID(cr.ownerID, changes.UpdateOld), + Delete: endpoint.FilterEndpointsByOwnerID(cr.ownerID, changes.Delete), + } + + for _, r := range filteredChanges.Create { + record := apiv1alpha1.DNSRecord{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-%s", cr.OwnerID(), r.SetIdentifier), + Namespace: cr.namespace, + Labels: map[string]string{ + apiv1alpha1.RecordOwnerLabel: cr.OwnerID(), + apiv1alpha1.RecordNameLabel: r.DNSName, + apiv1alpha1.RecordTypeLabel: r.RecordType, + apiv1alpha1.RecordIdentifierLabel: r.SetIdentifier, + }, + }, + Spec: apiv1alpha1.DNSRecordSpec{ + Endpoint: *r, + }, + } + + result := cr.client.Post().Namespace(cr.namespace).Body(&record).Do(ctx) + if err := result.Error(); err != nil { + // It could be possible that a record already exists if a previous apply change happened + // and there was an error while creating those records through the provider. For that reason, + // this error is ignored, all others will be surfaced back to the user + if !k8sErrors.IsAlreadyExists(err) { + return err + } + } + + if cr.cacheInterval > 0 { + cr.addToCache(r) + } + } + + for _, r := range filteredChanges.Delete { + var records apiv1alpha1.DNSRecordList + opts := metav1.ListOptions{ + LabelSelector: fmt.Sprintf("%s=%s,%s=%s", apiv1alpha1.RecordIdentifierLabel, r.SetIdentifier, apiv1alpha1.RecordOwnerLabel, cr.ownerID), + } + + err := cr.client.Get().Namespace(cr.namespace).Params(&opts).Do(ctx).Into(&records) + if err != nil { + return err + } + + // While this is a list, it is expected that this call will return 0 or 1 records. + for _, e := range records.Items { + result := cr.client.Delete().Namespace(cr.namespace).Name(e.Name).Do(ctx) + if err := result.Error(); err != nil { + // Ignore not found as it's a benign error, the record isn't present and it's the end goal here, to remove + // all records. All other errors should surface back to the user. + if !k8sErrors.IsNotFound(err) { + return err + } + } + } + + if cr.cacheInterval > 0 { + cr.removeFromCache(r) + } + } + + // Update existing DNS records to reflect the newest change. + for i, e := range filteredChanges.UpdateNew { + old := filteredChanges.UpdateOld[i] + + var records apiv1alpha1.DNSRecordList + opts := metav1.ListOptions{ + LabelSelector: fmt.Sprintf("%s=%s,%s=%s", apiv1alpha1.RecordIdentifierLabel, old.SetIdentifier, apiv1alpha1.RecordOwnerLabel, cr.ownerID), + } + + err := cr.client.Get().Namespace(cr.namespace).Params(&opts).Do(ctx).Into(&records) + if err != nil { + return err + } + + for _, record := range records.Items { + record.Spec.Endpoint = *e + result := cr.client.Put().Namespace(cr.namespace).Name(record.Name).Body(&record).Do(ctx) + if err := result.Error(); err != nil { + return err + } + } + + if cr.cacheInterval > 0 { + cr.addToCache(e) + } + + if cr.cacheInterval > 0 { + cr.removeFromCache(old) + } + } + + return cr.provider.ApplyChanges(ctx, filteredChanges) +} + +// AdjustEndpoints modifies the endpoints as needed by the specific provider +func (cr *CRDRegistry) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) { + return cr.provider.AdjustEndpoints(endpoints) +} + +func (cr *CRDRegistry) addToCache(ep *endpoint.Endpoint) { + if cr.recordsCache != nil { + cr.recordsCache = append(cr.recordsCache, ep) + } +} + +func (cr *CRDRegistry) removeFromCache(ep *endpoint.Endpoint) { + if cr.recordsCache == nil || ep == nil { + return + } + + for i, e := range cr.recordsCache { + if e.DNSName == ep.DNSName && e.RecordType == ep.RecordType && e.SetIdentifier == ep.SetIdentifier && e.Targets.Same(ep.Targets) { + // We found a match delete the endpoint from the cache. + cr.recordsCache = append(cr.recordsCache[:i], cr.recordsCache[i+1:]...) + return + } + } +} + +type crdclient struct { + scheme *runtime.Scheme + resource *metav1.APIResource + codec runtime.ParameterCodec + rest.Interface +} + +func (c crdclient) Get() CRDRequest { + return &crdrequest{client: &c, method: "GET", resource: c.resource} +} + +func (c crdclient) List() CRDRequest { + return &crdrequest{client: &c, method: "LIST", resource: c.resource} +} + +func (c crdclient) Post() CRDRequest { + return &crdrequest{client: &c, method: "POST", resource: c.resource} +} + +func (c crdclient) Put() CRDRequest { + return &crdrequest{client: &c, method: "PUT", resource: c.resource} +} + +func (c crdclient) Delete() CRDRequest { + return &crdrequest{client: &c, method: "DELETE", resource: c.resource} +} + +type crdrequest struct { + client *crdclient + resource *metav1.APIResource + + method string + namespace string + name string + params runtime.Object + body interface{} +} + +func (r *crdrequest) Name(name string) CRDRequest { + r.name = name + return r +} + +func (r *crdrequest) Namespace(namespace string) CRDRequest { + r.namespace = namespace + return r +} + +func (r *crdrequest) Params(obj runtime.Object) CRDRequest { + r.params = obj + return r +} + +func (r *crdrequest) Body(obj interface{}) CRDRequest { + r.body = obj + return r +} + +func (r *crdrequest) Do(ctx context.Context) CRDResult { + var req *rest.Request + switch r.method { + case "POST": + req = r.client.Interface.Post() + case "PUT": + req = r.client.Interface.Put() + case "DELETE": + req = r.client.Interface.Delete() + default: + req = r.client.Interface.Get() + } + + req = req.Namespace(r.namespace).Resource(r.resource.Name) + if r.name != "" { + req = req.Name(r.name) + } + + if r.params != nil { + req = req.VersionedParams(r.params, r.client.codec) + } + + if r.body != nil { + req = req.Body(r.body) + } + + result := req.Do(ctx) + return &crdresult{result} +} + +type crdresult struct { + rest.Result +} + +func (r crdresult) Error() error { + return r.Result.Error() +} + +func (r crdresult) Into(obj runtime.Object) error { + return r.Result.Into(obj) +} diff --git a/registry/crd_test.go b/registry/crd_test.go new file mode 100644 index 0000000000..3830a8e230 --- /dev/null +++ b/registry/crd_test.go @@ -0,0 +1,530 @@ +/* +Copyright 2025 The Kubernetes 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 registry + +import ( + "context" + "fmt" + "reflect" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + + apiv1alpha1 "sigs.k8s.io/external-dns/apis/v1alpha1" + "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/plan" + "sigs.k8s.io/external-dns/provider/inmemory" +) + +type CRDSuite struct { + suite.Suite +} + +func (suite *CRDSuite) SetupTest() { +} + +// The endpoints needs to be part of the zone otherwise it will be filtered out. +func inMemoryProviderWithEntries(t *testing.T, ctx context.Context, zone string, endpoints ...*endpoint.Endpoint) *inmemory.InMemoryProvider { + p := inmemory.NewInMemoryProvider(inmemory.InMemoryInitZones([]string{zone})) + + err := p.ApplyChanges(ctx, &plan.Changes{ + Create: endpoints, + }) + if err != nil { + t.Fatal("Could not create an in memory provider", err) + } + + return p +} + +func TestCRDSource(t *testing.T) { + suite.Run(t, new(CRDSuite)) + t.Run("Interface", testCRDSourceImplementsSource) + t.Run("Constructor", testConstructor) + t.Run("Records", testRecords) +} + +// testCRDSourceImplementsSource tests that crdSource is a valid Source. +func testCRDSourceImplementsSource(t *testing.T) { + require.Implements(t, (*Registry)(nil), new(CRDRegistry)) +} + +func testConstructor(t *testing.T) { + _, err := NewCRDRegistry(nil, "", "", "v1", "", "", time.Second, time.Second) + assert.Error(t, err, "Expected a new registry to return an error when no ownerID are specified") + + _, err = NewCRDRegistry(nil, "/dev/null", "", "v1", "default", "ownerID", time.Second, time.Second) + assert.Error(t, err, err.Error()+"Expected a new registry to return an error when there is no kubeconfig") + + _, err = NewCRDRegistry(nil, "", "####", "v1", "default", "ownerID", time.Second, time.Second) + assert.Error(t, err, err.Error()+"Expected a new registry to return an error when there is an invalid url") +} + +func testRecords(t *testing.T) { + ctx := context.Background() + t.Run("use the cache if within the time interval", func(t *testing.T) { + registry := &CRDRegistry{ + recordsCacheRefreshTime: time.Now(), + cacheInterval: time.Hour, + recordsCache: []*endpoint.Endpoint{{ + DNSName: "cached.mytestdomain.io", + RecordType: "A", + Targets: []string{"127.0.0.1"}, + }}, + } + endpoints, err := registry.Records(ctx) + if err != nil { + t.Error(err) + } + + if len(endpoints) != 1 { + t.Error("expected only 1 record from the cache, got: ", len(endpoints)) + } + + if endpoints[0].DNSName != "cached.mytestdomain.io" { + t.Error("expected DNS Name to be the cached value got: ", endpoints[0].DNSName) + } + }) + + t.Run("ALIAS records are converted to CNAME", func(t *testing.T) { + e := []*endpoint.Endpoint{ + { + DNSName: "foo.mytestdomain.io", + RecordType: "A", + Targets: []string{"127.0.0.1"}, + ProviderSpecific: []endpoint.ProviderSpecificProperty{{ + Name: "alias", + Value: "true", + }}, + }, + } + provider := inMemoryProviderWithEntries(t, ctx, "mytestdomain.io", e...) + responses := []mockResult{{ + request: mockRequest{ + method: "GET", + namespace: "default", + }, + response: &mockResponse{ + content: apiv1alpha1.DNSRecordList{ + Items: []apiv1alpha1.DNSRecord{{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + apiv1alpha1.RecordResourceLabel: "some-value", + }, + }, + Spec: apiv1alpha1.DNSRecordSpec{ + Endpoint: *e[0], + }, + }}, + }, + }, + }} + + registry := &CRDRegistry{ + provider: provider, + namespace: "default", + client: NewMockCRDClient("default", responses...), + ownerID: "test", + } + + endpoints, err := registry.Records(ctx) + if err != nil { + t.Error(err) + } + + if endpoints[0].RecordType != "CNAME" { + t.Error("Expected record type to be changed from ALIAS to CNAME: ", endpoints[0].RecordType) + } + }) + + t.Run("Add existing labels from registry to the record from the provider", func(t *testing.T) { + // Setup the provider and the mock client for the CRD so that mytestdomain.io can be + // found on both the provider and the CRD + provider := inMemoryProviderWithEntries(t, ctx, "mytestdomain.io", &endpoint.Endpoint{ + DNSName: "sub.mytestdomain.io", + RecordType: "CNAME", + SetIdentifier: "myid-1", + }) + + responses := []mockResult{{ + request: mockRequest{ + method: "GET", + namespace: "default", + }, + response: &mockResponse{ + content: apiv1alpha1.DNSRecordList{ + Items: []apiv1alpha1.DNSRecord{{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + apiv1alpha1.RecordResourceLabel: "some-value", + }, + }, + Spec: apiv1alpha1.DNSRecordSpec{ + Endpoint: endpoint.Endpoint{ + DNSName: "sub.mytestdomain.io", + RecordType: "CNAME", + SetIdentifier: "myid-1", + }, + }, + }}, + }, + }, + }} + + client := NewMockCRDClient("default", responses...) + + registry := &CRDRegistry{ + provider: provider, + namespace: "default", + client: client, + ownerID: "test", + } + + // The test + endpoints, err := registry.Records(ctx) + if err != nil { + t.Error(err) + } + + if len(endpoints) != 1 { + t.Errorf("expected only 1 endpoint, got %d", len(endpoints)) + } + + if endpoints[0].Labels[endpoint.ResourceLabelKey] != "some-value" { + t.Errorf("endpoint doesn't include the label from the registry: %#v", endpoints[0].Labels) + } + }) +} + +func TestCRDApplyChanges(t *testing.T) { + ctx := context.Background() + + testCases := []struct { + ChangeType string // One of Create, Update, Delete + Endpoint *endpoint.Endpoint + AssertFn func(c *mockClient) + }{ + { + ChangeType: "Create", + Endpoint: &endpoint.Endpoint{ + DNSName: "sub.mytestdomain.io", + RecordType: "CNAME", + SetIdentifier: "myid-1", + Targets: endpoint.NewTargets("127.0.0.1"), + Labels: map[string]string{ + endpoint.OwnerLabelKey: "test", + }, + }, + AssertFn: func(c *mockClient) { + executed := c.RequestWasExecuted(keyFromRequest(&mockRequest{ + method: "POST", + namespace: "default", + })) + + assert.True(t, executed) + }, + }, + { + ChangeType: "Delete", + Endpoint: &endpoint.Endpoint{ + DNSName: "to.be.deleted.mytestdomain.io", + RecordType: "A", + SetIdentifier: "myid-2", + Targets: endpoint.NewTargets("127.0.0.1"), + Labels: map[string]string{ + endpoint.OwnerLabelKey: "test", + }, + }, + AssertFn: func(c *mockClient) { + executed := c.RequestWasExecuted(keyFromRequest(&mockRequest{ + method: "DELETE", + namespace: "default", + name: "test-myid-2", // OwnerID = test; IdentifierID = myid-2 + })) + + assert.True(t, executed) + }, + }, + { + ChangeType: "Update", + Endpoint: &endpoint.Endpoint{ + DNSName: "to.be.updated.mytestdomain.io", + RecordType: "CNAME", + SetIdentifier: "myid-3", + Targets: endpoint.NewTargets("127.0.0.1"), + Labels: map[string]string{ + endpoint.OwnerLabelKey: "test", + }, + }, + AssertFn: func(c *mockClient) { + executed := c.RequestWasExecuted(keyFromRequest(&mockRequest{ + method: "PUT", + namespace: "default", + name: "test-myid-3", // OwnerID = test; IdentifierID = myid-2 + })) + + assert.True(t, executed) + }, + }, + } + + for _, testCase := range testCases { + var seedEndpoints []*endpoint.Endpoint + var changes plan.Changes + var responses []mockResult + switch testCase.ChangeType { + case "Create": + responses = append(responses, mockResult{ + request: mockRequest{ + method: "POST", + namespace: "default", + }, + response: &mockResponse{ + content: apiv1alpha1.DNSRecord{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + apiv1alpha1.RecordResourceLabel: "some-value", + }, + }, + Spec: apiv1alpha1.DNSRecordSpec{Endpoint: *testCase.Endpoint}, + }, + }, + }) + + changes.Create = []*endpoint.Endpoint{testCase.Endpoint} + + case "Delete": + responses = append(responses, mockResult{ + request: mockRequest{ + method: "GET", + namespace: "default", + }, + response: &mockResponse{ + content: apiv1alpha1.DNSRecordList{ + Items: []apiv1alpha1.DNSRecord{{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-myid-2", + Namespace: "default", + }, + Spec: apiv1alpha1.DNSRecordSpec{ + Endpoint: *testCase.Endpoint, + }, + }}, + }, + }, + }, mockResult{ + request: mockRequest{ + method: "DELETE", + name: "test-myid-2", + namespace: "default", + }, + response: &mockResponse{}, + }) + + changes.Delete = []*endpoint.Endpoint{testCase.Endpoint} + seedEndpoints = append(seedEndpoints, testCase.Endpoint) + case "Update": + responses = append(responses, mockResult{ + request: mockRequest{ + method: "GET", + namespace: "default", + }, + response: &mockResponse{ + content: apiv1alpha1.DNSRecordList{ + Items: []apiv1alpha1.DNSRecord{{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-myid-3", + Namespace: "default", + }, + Spec: apiv1alpha1.DNSRecordSpec{ + Endpoint: *testCase.Endpoint, + }, + }}, + }, + }, + }, mockResult{ + request: mockRequest{ + method: "PUT", + name: "test-myid-3", + namespace: "default", + }, + response: &mockResponse{}, + }) + + changes.UpdateNew = []*endpoint.Endpoint{testCase.Endpoint} + changes.UpdateOld = []*endpoint.Endpoint{testCase.Endpoint} + seedEndpoints = append(seedEndpoints, testCase.Endpoint) + default: + t.Errorf("ChangeType not defined: %s", testCase.ChangeType) + } + + provider := inMemoryProviderWithEntries(t, ctx, "mytestdomain.io", seedEndpoints...) + client := NewMockCRDClient("default", responses...) + registry := &CRDRegistry{ + provider: provider, + namespace: "default", + client: client, + ownerID: "test", + } + + err := registry.ApplyChanges(ctx, &changes) + if err != nil { + t.Error(err) + } + + if testCase.AssertFn != nil { + testCase.AssertFn(client) + } + + } +} + +// Mocking tools for the CRD. These are attempt at generating all the right struct +// for the test to be able to simulate requests and return proper objects so that +// tests can be conducted in a controlled fashion to exercise specific behavior without +// requiring a fully configured Kubernetes client. Hopefully it makes it easier to write tests +// while giving enough confidence that the tests represents real-life scenarios. +type mockResult struct { + request mockRequest + response CRDResult +} + +type mockClient struct { + namespace string + mockResponses map[mockRequestKey]CRDResult + requestHit map[mockRequestKey]struct{} +} + +func NewMockCRDClient(namespace string, responses ...mockResult) *mockClient { + mockResponses := map[mockRequestKey]CRDResult{} + for _, r := range responses { + mockResponses[keyFromRequest(&r.request)] = r.response + } + + return &mockClient{ + namespace: namespace, + mockResponses: mockResponses, + requestHit: map[mockRequestKey]struct{}{}, + } +} + +func (mc mockClient) RequestWasExecuted(key mockRequestKey) bool { + _, found := mc.requestHit[key] + return found +} + +func (m *mockClient) MockResponses() { +} + +func (m *mockClient) Get() CRDRequest { + return &mockRequest{c: m, namespace: m.namespace, method: "GET"} +} + +func (m *mockClient) List() CRDRequest { + return &mockRequest{c: m, namespace: m.namespace, method: "GET"} +} + +func (m *mockClient) Put() CRDRequest { + return &mockRequest{c: m, namespace: m.namespace, method: "PUT"} +} + +func (m *mockClient) Post() CRDRequest { + return &mockRequest{c: m, namespace: m.namespace, method: "POST"} +} + +func (m *mockClient) Delete() CRDRequest { + return &mockRequest{c: m, namespace: m.namespace, method: "DELETE"} +} + +type mockRequestKey struct { + method string + namespace string + name string +} + +func keyFromRequest(mr *mockRequest) mockRequestKey { + return mockRequestKey{ + method: mr.method, + name: mr.name, + namespace: mr.namespace, + } +} + +type mockRequest struct { + c *mockClient + method string + namespace string + name string +} + +func (mr *mockRequest) Name(name string) CRDRequest { + mr.name = name + return mr +} + +func (mr *mockRequest) Namespace(namespace string) CRDRequest { + mr.namespace = namespace + return mr +} + +func (mr *mockRequest) Body(interface{}) CRDRequest { + return mr +} + +func (mr *mockRequest) Params(runtime.Object) CRDRequest { + return mr +} + +func (mr *mockRequest) Do(ctx context.Context) CRDResult { + key := keyFromRequest(mr) + if response, found := mr.c.mockResponses[key]; found { + mr.c.requestHit[key] = struct{}{} + return response + } + + return &mockErrorResponse{request: mr} +} + +type mockErrorResponse struct { + request *mockRequest +} + +func (mr *mockErrorResponse) Error() error { + return fmt.Errorf("Request wasn't mocked: %+v", mr.request) +} + +func (mr *mockErrorResponse) Into(obj runtime.Object) error { + return fmt.Errorf("Request wasn't mocked: %+v", mr.request) +} + +type mockResponse struct { + content any +} + +func (mr *mockResponse) Error() error { + return nil +} + +func (mr *mockResponse) Into(obj runtime.Object) error { + reflect.ValueOf(obj).Elem().Set(reflect.ValueOf(mr.content)) + return nil +}